diff --git a/internal/ai/ai.go b/internal/ai/ai.go new file mode 100644 index 0000000..9d73b9f --- /dev/null +++ b/internal/ai/ai.go @@ -0,0 +1,369 @@ +// Package ai is a provider-agnostic layer over chat completions. +// +// One interface, adapters for Anthropic, OpenAI, Google, and anything speaking the +// OpenAI-compatible shape — which is what a locally-run model behind Ollama, vLLM +// or LM Studio offers. The last of those is why the abstraction exists at all: the +// owner runs their own models, and an integration that only spoke to a hosted API +// would be useless to them. +// +// Every feature that uses this degrades to nothing when no provider is configured. +// AI here is a convenience on top of a to-do app that works without it, not a +// dependency the app has acquired. +package ai + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "strings" + "time" +) + +// ErrNotConfigured is returned when no provider is set up. Callers treat it as +// "this feature is off", not as a failure. +var ErrNotConfigured = errors.New("ai: no provider is configured") + +type Provider string + +const ( + ProviderAnthropic Provider = "anthropic" + ProviderOpenAI Provider = "openai" + ProviderGoogle Provider = "google" + // ProviderCompatible is any OpenAI-shaped endpoint: Ollama, vLLM, LM Studio, + // OpenRouter, a company gateway. Base URL and model are configured by hand. + ProviderCompatible Provider = "compatible" +) + +type Config struct { + Provider Provider + APIKey string + // BaseURL overrides the provider's default endpoint. Required for + // ProviderCompatible and useful for a proxy in front of the others. + BaseURL string + Model string +} + +func (c Config) Configured() bool { + if c.Provider == "" || c.Model == "" { + return false + } + // A local model needs no key, which is exactly the case a key check would + // break — so the requirement is per-provider rather than universal. + if c.Provider == ProviderCompatible { + return c.BaseURL != "" + } + return c.APIKey != "" +} + +type Message struct { + Role string `json:"role"` // "user" or "assistant" + Content string `json:"content"` +} + +// Client talks to whichever provider is configured. +type Client struct { + cfg Config + http *http.Client +} + +func New(cfg Config) *Client { + return &Client{ + cfg: cfg, + // Generous: a local model on modest hardware can take a while to produce + // its first token, and the alternative is a timeout that only ever fires + // for the people running their own. + http: &http.Client{Timeout: 120 * time.Second}, + } +} + +// Complete sends a system prompt and a conversation, and returns the reply. +func (c *Client) Complete(ctx context.Context, system string, messages []Message) (string, error) { + if !c.cfg.Configured() { + return "", ErrNotConfigured + } + switch c.cfg.Provider { + case ProviderAnthropic: + return c.anthropic(ctx, system, messages) + case ProviderGoogle: + return c.google(ctx, system, messages) + default: + // OpenAI and every compatible endpoint share one request shape. + return c.openAICompatible(ctx, system, messages) + } +} + +// --- Anthropic -------------------------------------------------------------------- + +func (c *Client) anthropic(ctx context.Context, system string, messages []Message) (string, error) { + base := c.cfg.BaseURL + if base == "" { + base = "https://api.anthropic.com" + } + + body := map[string]any{ + "model": c.cfg.Model, + "max_tokens": 2048, + "system": system, + "messages": messages, + } + req, err := c.request(ctx, base+"/v1/messages", body) + if err != nil { + return "", err + } + req.Header.Set("x-api-key", c.cfg.APIKey) + req.Header.Set("anthropic-version", "2023-06-01") + + var parsed struct { + Content []struct { + Type string `json:"type"` + Text string `json:"text"` + } `json:"content"` + } + if err := c.do(req, &parsed); err != nil { + return "", err + } + + var out strings.Builder + for _, block := range parsed.Content { + if block.Type == "text" { + out.WriteString(block.Text) + } + } + return out.String(), nil +} + +// --- OpenAI and compatible ---------------------------------------------------------- + +func (c *Client) openAICompatible(ctx context.Context, system string, messages []Message) (string, error) { + base := c.cfg.BaseURL + if base == "" { + base = "https://api.openai.com" + } + base = strings.TrimSuffix(base, "/") + // A locally-run endpoint is usually given as ".../v1" already; appending it + // again is the single most common way this is misconfigured. + if !strings.HasSuffix(base, "/v1") { + base += "/v1" + } + + all := append([]map[string]string{{"role": "system", "content": system}}, toOpenAI(messages)...) + req, err := c.request(ctx, base+"/chat/completions", map[string]any{ + "model": c.cfg.Model, + "messages": all, + }) + if err != nil { + return "", err + } + if c.cfg.APIKey != "" { + req.Header.Set("Authorization", "Bearer "+c.cfg.APIKey) + } + + var parsed struct { + Choices []struct { + Message struct { + Content string `json:"content"` + } `json:"message"` + } `json:"choices"` + } + if err := c.do(req, &parsed); err != nil { + return "", err + } + if len(parsed.Choices) == 0 { + return "", fmt.Errorf("ai: the model returned nothing") + } + return parsed.Choices[0].Message.Content, nil +} + +func toOpenAI(messages []Message) []map[string]string { + out := make([]map[string]string, 0, len(messages)) + for _, m := range messages { + out = append(out, map[string]string{"role": m.Role, "content": m.Content}) + } + return out +} + +// --- Google ------------------------------------------------------------------------- + +func (c *Client) google(ctx context.Context, system string, messages []Message) (string, error) { + base := c.cfg.BaseURL + if base == "" { + base = "https://generativelanguage.googleapis.com" + } + + contents := make([]map[string]any, 0, len(messages)) + for _, m := range messages { + role := "user" + if m.Role == "assistant" { + role = "model" // Google's name for the same thing + } + contents = append(contents, map[string]any{ + "role": role, "parts": []map[string]string{{"text": m.Content}}, + }) + } + + body := map[string]any{ + "contents": contents, + "systemInstruction": map[string]any{"parts": []map[string]string{{"text": system}}}, + } + url := fmt.Sprintf("%s/v1beta/models/%s:generateContent?key=%s", base, c.cfg.Model, c.cfg.APIKey) + req, err := c.request(ctx, url, body) + if err != nil { + return "", err + } + + var parsed struct { + Candidates []struct { + Content struct { + Parts []struct { + Text string `json:"text"` + } `json:"parts"` + } `json:"content"` + } `json:"candidates"` + } + if err := c.do(req, &parsed); err != nil { + return "", err + } + if len(parsed.Candidates) == 0 { + return "", fmt.Errorf("ai: the model returned nothing") + } + + var out strings.Builder + for _, part := range parsed.Candidates[0].Content.Parts { + out.WriteString(part.Text) + } + return out.String(), nil +} + +// --- plumbing --------------------------------------------------------------------------- + +func (c *Client) request(ctx context.Context, url string, body any) (*http.Request, error) { + raw, err := json.Marshal(body) + if err != nil { + return nil, err + } + req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(raw)) + if err != nil { + return nil, err + } + req.Header.Set("Content-Type", "application/json") + return req, nil +} + +func (c *Client) do(req *http.Request, out any) error { + resp, err := c.http.Do(req) + if err != nil { + return fmt.Errorf("ai: %w", err) + } + defer resp.Body.Close() + + raw, err := io.ReadAll(io.LimitReader(resp.Body, 4<<20)) + if err != nil { + return err + } + if resp.StatusCode >= 300 { + // The provider's own message is included: for a local model it is usually + // the actual problem ("model not found"), and hiding it would leave the + // operator with nothing to go on. + return fmt.Errorf("ai: %s said %s: %s", req.URL.Host, resp.Status, truncate(string(raw), 300)) + } + return json.Unmarshal(raw, out) +} + +func truncate(s string, max int) string { + if len(s) <= max { + return s + } + return s[:max] + "…" +} + +// --- features ------------------------------------------------------------------------ + +// SplitIntoSubtasks asks for a task to be broken down. +// +// The prompt insists on a bare JSON array because the result is parsed, and a model +// that helpfully wraps it in prose or a code fence produces a parse failure that +// looks to the user like the feature being broken. +func (c *Client) SplitIntoSubtasks(ctx context.Context, task, description, locale string) ([]string, error) { + language := "Danish" + if strings.HasPrefix(strings.ToLower(locale), "en") { + language = "English" + } + + system := fmt.Sprintf(`You break a task into concrete sub-tasks. + +Reply with a JSON array of strings and nothing else — no prose, no code fence. +Write the sub-tasks in %s. Give between two and seven of them. Each one should be +a single concrete action somebody could do in one sitting, phrased the way the +original task is phrased. Do not restate the original task as a sub-task.`, language) + + prompt := "Task: " + task + if description != "" { + prompt += "\nNotes: " + description + } + + reply, err := c.Complete(ctx, system, []Message{{Role: "user", Content: prompt}}) + if err != nil { + return nil, err + } + + var subtasks []string + if err := json.Unmarshal([]byte(extractJSON(reply)), &subtasks); err != nil { + return nil, fmt.Errorf("ai: the model did not return a list: %s", truncate(reply, 200)) + } + + out := make([]string, 0, len(subtasks)) + for _, s := range subtasks { + if s = strings.TrimSpace(s); s != "" { + out = append(out, s) + } + } + if len(out) == 0 { + return nil, fmt.Errorf("ai: the model returned an empty list") + } + return out, nil +} + +// WeeklySummary produces a short prioritisation note over what is outstanding. +func (c *Client) WeeklySummary(ctx context.Context, tasks []string, locale string) (string, error) { + language := "Danish" + if strings.HasPrefix(strings.ToLower(locale), "en") { + language = "English" + } + + system := fmt.Sprintf(`You help somebody decide what to do this week. + +Write in %s. Be brief — at most six short lines. Say what looks most urgent and +why, and name anything that appears to be slipping. Do not restate the whole list +back, do not congratulate, and do not offer productivity advice.`, language) + + prompt := "These are the open tasks:\n" + strings.Join(tasks, "\n") + return c.Complete(ctx, system, []Message{{Role: "user", Content: prompt}}) +} + +// extractJSON pulls an array out of a reply that may have been wrapped in a code +// fence or preceded by a sentence, which models do however firmly they are asked +// not to. +func extractJSON(reply string) string { + reply = strings.TrimSpace(reply) + + if fence := strings.Index(reply, "```"); fence >= 0 { + rest := reply[fence+3:] + if newline := strings.IndexByte(rest, '\n'); newline >= 0 { + rest = rest[newline+1:] + } + if end := strings.Index(rest, "```"); end >= 0 { + reply = strings.TrimSpace(rest[:end]) + } + } + + start := strings.IndexByte(reply, '[') + end := strings.LastIndexByte(reply, ']') + if start >= 0 && end > start { + return reply[start : end+1] + } + return reply +} diff --git a/internal/caldav/caldav.go b/internal/caldav/caldav.go new file mode 100644 index 0000000..9968a7c --- /dev/null +++ b/internal/caldav/caldav.go @@ -0,0 +1,236 @@ +// Package caldav implements the WebDAV and CalDAV verbs a task client needs +// (RFC 4791, over RFC 4918), so Apple Reminders and Thunderbird can read, create +// and complete tasks in verdande directly. +// +// Only the VTODO half is implemented, and only the parts a real client actually +// uses: PROPFIND to discover the collection, REPORT to fetch its contents, and +// GET/PUT/DELETE on individual items. Calendar scheduling, free/busy and the whole +// VEVENT side are not here, because nothing that reads tasks asks for them. +// +// XML is written as text rather than through encoding/xml. The namespace prefixing +// CalDAV requires — DAV:, caldav and calendarserver all interleaved in one +// document — is genuinely more awkward to express through struct tags than to write +// out, and the shape of these responses is fixed. +package caldav + +import ( + "encoding/xml" + "fmt" + "strings" +) + +// Namespaces, with the prefixes clients expect to see. +const ( + NSDav = "DAV:" + NSCalDAV = "urn:ietf:params:xml:ns:caldav" + NSCalSrv = "http://calendarserver.org/ns/" + NSApple = "http://apple.com/ns/ical/" +) + +// PropfindRequest is the parsed body of a PROPFIND, which names the properties the +// client is asking about. +type PropfindRequest struct { + // AllProp is set when the client asked for everything rather than a list. + AllProp bool + Props []string +} + +// ParsePropfind reads a PROPFIND body. An empty body means allprop, which is what +// the spec says and what some clients rely on. +func ParsePropfind(body []byte) PropfindRequest { + if len(strings.TrimSpace(string(body))) == 0 { + return PropfindRequest{AllProp: true} + } + + var doc struct { + XMLName xml.Name + AllProp *struct{} `xml:"DAV: allprop"` + Prop struct { + Any []xml.Name `xml:",any"` + } `xml:"DAV: prop"` + } + if err := xml.Unmarshal(body, &doc); err != nil { + // A body that will not parse is treated as allprop rather than as an + // error: clients send surprising things, and answering with everything is + // always a valid response to a PROPFIND. + return PropfindRequest{AllProp: true} + } + if doc.AllProp != nil { + return PropfindRequest{AllProp: true} + } + + req := PropfindRequest{} + for _, name := range doc.Prop.Any { + req.Props = append(req.Props, name.Space+":"+name.Local) + } + return req +} + +func (r PropfindRequest) Wants(space, local string) bool { + if r.AllProp { + return true + } + for _, p := range r.Props { + if p == space+":"+local { + return true + } + } + return false +} + +// Multistatus builds a 207 response body. +type Multistatus struct { + responses []string +} + +// AddCollection describes a calendar collection — the project itself. +func (m *Multistatus) AddCollection(href, displayName, ctag string, req PropfindRequest) { + var props []string + + if req.Wants(NSDav, "resourcetype") { + props = append(props, ``) + } + if req.Wants(NSDav, "displayname") { + props = append(props, ``+escapeXML(displayName)+``) + } + if req.Wants(NSCalDAV, "supported-calendar-component-set") { + // VTODO only. Announcing VEVENT as well would make Apple Calendar offer to + // put appointments here, which it would then have nowhere to store. + props = append(props, + ``) + } + if req.Wants(NSCalSrv, "getctag") { + // The collection tag: clients poll it and only re-sync when it changes. + // Getting this wrong means either constant full syncs or stale data. + props = append(props, ``+escapeXML(ctag)+``) + } + if req.Wants(NSDav, "getetag") { + props = append(props, `"`+escapeXML(ctag)+`"`) + } + if req.Wants(NSApple, "calendar-color") { + props = append(props, `#5FBF8FFF`) + } + if req.Wants(NSCalDAV, "calendar-description") { + props = append(props, ``+escapeXML(displayName)+``) + } + + m.add(href, props) +} + +// AddItem describes one VTODO resource. +func (m *Multistatus) AddItem(href, etag, calendarData string, req PropfindRequest) { + var props []string + + if req.Wants(NSDav, "getetag") { + props = append(props, `"`+escapeXML(etag)+`"`) + } + if req.Wants(NSDav, "resourcetype") { + props = append(props, ``) + } + if req.Wants(NSDav, "getcontenttype") { + props = append(props, `text/calendar; component=vtodo`) + } + if calendarData != "" && req.Wants(NSCalDAV, "calendar-data") { + props = append(props, ``+escapeXML(calendarData)+``) + } + + m.add(href, props) +} + +// AddPrincipal describes the user, which is what a client asks for first when it is +// working out where its calendars live. +func (m *Multistatus) AddPrincipal(href, calendarHome, displayName string, req PropfindRequest) { + var props []string + + if req.Wants(NSDav, "resourcetype") { + props = append(props, ``) + } + if req.Wants(NSDav, "displayname") { + props = append(props, ``+escapeXML(displayName)+``) + } + if req.Wants(NSDav, "current-user-principal") || req.Wants(NSDav, "principal-URL") { + props = append(props, + ``+escapeXML(href)+``, + ``+escapeXML(href)+``) + } + if req.Wants(NSCalDAV, "calendar-home-set") { + props = append(props, + ``+escapeXML(calendarHome)+``) + } + + m.add(href, props) +} + +func (m *Multistatus) add(href string, props []string) { + if len(props) == 0 { + // A response with no properties still has to exist, or the client sees the + // resource as absent rather than as having nothing it asked for. + props = append(props, ``) + } + m.responses = append(m.responses, fmt.Sprintf( + `%s%sHTTP/1.1 200 OK`, + escapeXML(href), strings.Join(props, ""))) +} + +// AddNotFound records a resource the client asked about that does not exist. Needed +// by sync: it is how a client learns a task was deleted somewhere else. +func (m *Multistatus) AddNotFound(href string) { + m.responses = append(m.responses, fmt.Sprintf( + `%sHTTP/1.1 404 Not Found`, + escapeXML(href))) +} + +func (m *Multistatus) XML() string { + return `` + + `` + + strings.Join(m.responses, "") + + `` +} + +// --- REPORT --------------------------------------------------------------------------- + +// ReportRequest is the parsed body of a calendar-query or calendar-multiget. +type ReportRequest struct { + // Multiget lists specific hrefs; a query asks for everything matching a filter. + Multiget bool + Hrefs []string + Props PropfindRequest +} + +func ParseReport(body []byte) ReportRequest { + var doc struct { + XMLName xml.Name + Prop struct { + Any []xml.Name `xml:",any"` + } `xml:"DAV: prop"` + Hrefs []string `xml:"DAV: href"` + } + if err := xml.Unmarshal(body, &doc); err != nil { + return ReportRequest{Props: PropfindRequest{AllProp: true}} + } + + req := ReportRequest{ + Multiget: strings.Contains(strings.ToLower(doc.XMLName.Local), "multiget"), + Hrefs: doc.Hrefs, + } + for _, name := range doc.Prop.Any { + req.Props.Props = append(req.Props.Props, name.Space+":"+name.Local) + } + if len(req.Props.Props) == 0 { + req.Props.AllProp = true + } + return req +} + +// escapeXML is deliberately its own function rather than xml.EscapeText: task titles +// arrive from users, and every one of them passes through here on its way into a +// document a client will parse. A missed escape is a client that fails to sync with +// a parse error nobody can see the cause of. +func escapeXML(s string) string { + var b strings.Builder + if err := xml.EscapeText(&b, []byte(s)); err != nil { + return "" + } + return b.String() +} diff --git a/internal/httpapi/caldav_handlers.go b/internal/httpapi/caldav_handlers.go new file mode 100644 index 0000000..48f0c7f --- /dev/null +++ b/internal/httpapi/caldav_handlers.go @@ -0,0 +1,392 @@ +package httpapi + +import ( + "fmt" + "io" + "net/http" + "strings" + "time" + + "github.com/go-chi/chi/v5" + + "github.com/kristianwind/verdande/internal/caldav" + "github.com/kristianwind/verdande/internal/ics" + "github.com/kristianwind/verdande/internal/store" +) + +// chi only routes the standard HTTP verbs. WebDAV's are registered here, once, +// before any router is built — PROPFIND and REPORT are the two a task client needs, +// and without this the router panics at construction rather than at request time. +func init() { + for _, method := range []string{"PROPFIND", "REPORT", "PROPPATCH", "MKCALENDAR"} { + chi.RegisterMethod(method) + } +} + +// CalDAV lives at /caldav and speaks HTTP Basic with an API token as the password. +// +// Basic rather than the session cookie because that is what CalDAV clients offer: +// Apple Reminders and Thunderbird both ask for a username and password and send +// them on every request. The username is the email address and the password is a +// personal API token — never the account password, so a client's stored credential +// cannot be used to sign in to the app or change the password. + +func (s *Server) caldavAuth(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + email, token, ok := r.BasicAuth() + if !ok { + s.caldavChallenge(w) + return + } + + user, err := s.db.UserByAPIToken(r.Context(), token) + if err != nil || !strings.EqualFold(user.Email, strings.TrimSpace(email)) { + s.caldavChallenge(w) + return + } + next.ServeHTTP(w, r.WithContext(withUser(r.Context(), user))) + }) +} + +func (s *Server) caldavChallenge(w http.ResponseWriter) { + w.Header().Set("WWW-Authenticate", `Basic realm="verdande"`) + http.Error(w, "unauthorized", http.StatusUnauthorized) +} + +// handleCalDAVOptions advertises what this server can do. Clients read the DAV +// header before they will try anything else; without "calendar-access" in it, +// Apple Reminders decides this is a plain WebDAV share and never looks for tasks. +func (s *Server) handleCalDAVOptions(w http.ResponseWriter, r *http.Request) { + w.Header().Set("DAV", "1, 2, 3, calendar-access") + w.Header().Set("Allow", "OPTIONS, GET, HEAD, PUT, DELETE, PROPFIND, REPORT") + w.WriteHeader(http.StatusOK) +} + +// handleCalDAVPrincipal answers the discovery request: who am I, and where are my +// calendars. +func (s *Server) handleCalDAVPrincipal(w http.ResponseWriter, r *http.Request) { + user := userFrom(r.Context()) + body, _ := io.ReadAll(io.LimitReader(r.Body, 1<<20)) + req := caldav.ParsePropfind(body) + + m := &caldav.Multistatus{} + m.AddPrincipal("/caldav/"+user.ID+"/", "/caldav/"+user.ID+"/", user.Name, req) + writeMultistatus(w, m) +} + +// handleCalDAVHome lists the calendars: one per project. +// +// A project per calendar rather than one calendar for everything, because that is +// the unit a person shares and the unit a client lets you turn on and off. +func (s *Server) handleCalDAVHome(w http.ResponseWriter, r *http.Request) { + user := userFrom(r.Context()) + body, _ := io.ReadAll(io.LimitReader(r.Body, 1<<20)) + req := caldav.ParsePropfind(body) + + projects, err := s.db.ListProjects(r.Context(), user.ID, false) + if err != nil { + s.internal(w, "caldav home", err) + return + } + + m := &caldav.Multistatus{} + m.AddCollection("/caldav/"+user.ID+"/", "verdande", "home", req) + + // Depth: 0 means the client wants the home itself and not its children. + if r.Header.Get("Depth") != "0" { + for _, p := range projects { + ctag, err := s.projectCTag(r, p.ID) + if err != nil { + continue + } + m.AddCollection("/caldav/"+user.ID+"/"+p.ID+"/", p.Name, ctag, req) + } + } + writeMultistatus(w, m) +} + +// handleCalDAVCalendar lists the tasks in one project. +func (s *Server) handleCalDAVCalendar(w http.ResponseWriter, r *http.Request) { + user := userFrom(r.Context()) + projectID := chi.URLParam(r, "projectID") + + if _, err := store.RequireProjectRole(r.Context(), s.db, projectID, user.ID, store.RoleViewer); err != nil { + http.NotFound(w, r) + return + } + + body, _ := io.ReadAll(io.LimitReader(r.Body, 1<<20)) + req := caldav.ParsePropfind(body) + + project, err := s.db.GetProject(r.Context(), projectID, user.ID) + if err != nil { + http.NotFound(w, r) + return + } + ctag, _ := s.projectCTag(r, projectID) + + m := &caldav.Multistatus{} + m.AddCollection("/caldav/"+user.ID+"/"+projectID+"/", project.Name, ctag, req) + + if r.Header.Get("Depth") != "0" { + tasks, err := s.caldavTasks(r, user, projectID) + if err != nil { + s.internal(w, "caldav tasks", err) + return + } + for _, t := range tasks { + m.AddItem(taskHref(user.ID, projectID, t.ID), taskETag(t), "", req) + } + } + writeMultistatus(w, m) +} + +// handleCalDAVReport answers calendar-query and calendar-multiget: the client +// asking for the actual VTODO bodies. +func (s *Server) handleCalDAVReport(w http.ResponseWriter, r *http.Request) { + user := userFrom(r.Context()) + projectID := chi.URLParam(r, "projectID") + + if _, err := store.RequireProjectRole(r.Context(), s.db, projectID, user.ID, store.RoleViewer); err != nil { + http.NotFound(w, r) + return + } + + body, _ := io.ReadAll(io.LimitReader(r.Body, 4<<20)) + req := caldav.ParseReport(body) + + tasks, err := s.caldavTasks(r, user, projectID) + if err != nil { + s.internal(w, "caldav report", err) + return + } + byID := map[string]store.Task{} + for _, t := range tasks { + byID[t.ID] = t + } + + m := &caldav.Multistatus{} + + if req.Multiget { + // The client named specific resources. Anything it asks for that is not + // here has to be reported as 404 — that is how it learns a task was + // deleted somewhere else. + for _, href := range req.Hrefs { + id := hrefTaskID(href) + task, ok := byID[id] + if !ok { + m.AddNotFound(href) + continue + } + m.AddItem(href, taskETag(task), s.renderVTODO(r, user, task), req.Props) + } + } else { + for _, t := range tasks { + m.AddItem(taskHref(user.ID, projectID, t.ID), taskETag(t), s.renderVTODO(r, user, t), req.Props) + } + } + writeMultistatus(w, m) +} + +// handleCalDAVGet returns one task as a calendar object. +func (s *Server) handleCalDAVGet(w http.ResponseWriter, r *http.Request) { + user := userFrom(r.Context()) + taskID := strings.TrimSuffix(chi.URLParam(r, "taskFile"), ".ics") + + task, err := s.db.GetTask(r.Context(), taskID, user.ID) + if err != nil { + http.NotFound(w, r) + return + } + + w.Header().Set("Content-Type", "text/calendar; charset=utf-8; component=vtodo") + w.Header().Set("ETag", `"`+taskETag(*task)+`"`) + w.Write([]byte(s.renderVTODO(r, user, *task))) +} + +// handleCalDAVPut creates or updates a task from a VTODO the client sends. +// +// This is the half that makes CalDAV two-way: ticking something off in Apple +// Reminders arrives here as a PUT with STATUS:COMPLETED. +func (s *Server) handleCalDAVPut(w http.ResponseWriter, r *http.Request) { + user := userFrom(r.Context()) + projectID := chi.URLParam(r, "projectID") + taskID := strings.TrimSuffix(chi.URLParam(r, "taskFile"), ".ics") + + if _, err := store.RequireProjectRole(r.Context(), s.db, projectID, user.ID, store.RoleEditor); err != nil { + http.Error(w, "forbidden", http.StatusForbidden) + return + } + + body, err := io.ReadAll(io.LimitReader(r.Body, 1<<20)) + if err != nil { + http.Error(w, "bad request", http.StatusBadRequest) + return + } + parsed, err := ics.ParseVTODO(string(body)) + if err != nil { + http.Error(w, "could not read the calendar object", http.StatusBadRequest) + return + } + + existing, getErr := s.db.GetTask(r.Context(), taskID, user.ID) + if getErr == nil { + // An update. Only the fields a task client can express are touched; + // everything else the task carries stays as it is. + content := parsed.Summary + update := store.TaskUpdate{Content: &content, Description: &parsed.Description} + update.SetDue = true + update.DueDate = parsed.DueDate + update.DueDatetime = parsed.DueDatetime + + if err := s.db.UpdateTask(r.Context(), taskID, user.ID, update); err != nil { + s.internal(w, "caldav update", err) + return + } + if parsed.Completed && !existing.Completed() { + if _, err := s.db.CompleteTask(r.Context(), taskID, user.ID); err != nil { + s.log.Warn("caldav complete", "err", err) + } + } + if !parsed.Completed && existing.Completed() { + if err := s.db.ReopenTask(r.Context(), taskID); err != nil { + s.log.Warn("caldav reopen", "err", err) + } + } + + updated, _ := s.db.GetTask(r.Context(), taskID, user.ID) + if updated != nil { + s.publish(projectID, "task.updated", toTaskJSON(*updated)) + w.Header().Set("ETag", `"`+taskETag(*updated)+`"`) + } + w.WriteHeader(http.StatusNoContent) + return + } + + // A new task. The client chose the resource name, so that becomes the id — + // which is what lets it address the task it just created without re-syncing. + task := &store.Task{ + ID: taskID, ProjectID: projectID, Content: parsed.Summary, + Description: parsed.Description, Priority: parsed.Priority, + DueDate: parsed.DueDate, DueDatetime: parsed.DueDatetime, + RecurrenceRule: parsed.Recurrence, CreatedBy: user.ID, + } + if strings.TrimSpace(task.Content) == "" { + task.Content = "(uden titel)" + } + if err := s.db.CreateTask(r.Context(), task, nil); err != nil { + s.internal(w, "caldav create", err) + return + } + s.publish(projectID, "task.created", toTaskJSON(*task)) + + w.Header().Set("ETag", `"`+taskETag(*task)+`"`) + w.WriteHeader(http.StatusCreated) +} + +func (s *Server) handleCalDAVDelete(w http.ResponseWriter, r *http.Request) { + user := userFrom(r.Context()) + taskID := strings.TrimSuffix(chi.URLParam(r, "taskFile"), ".ics") + + role, err := store.TaskRole(r.Context(), s.db, taskID, user.ID) + if err != nil || !role.CanEdit() { + http.NotFound(w, r) + return + } + if err := s.db.DeleteTask(r.Context(), taskID); err != nil { + s.internal(w, "caldav delete", err) + return + } + w.WriteHeader(http.StatusNoContent) +} + +// --- helpers --------------------------------------------------------------------------- + +func (s *Server) caldavTasks(r *http.Request, user *store.User, projectID string) ([]store.Task, error) { + return s.db.ListTasks(r.Context(), user.ID, store.TaskFilter{ + ProjectIDs: []string{projectID}, + // Completed tasks stay in the collection: a client that stops seeing them + // treats them as deleted and removes them from its own store, which loses + // the record of having done them. + IncludeCompleted: true, + Limit: 2000, + }) +} + +func (s *Server) renderVTODO(r *http.Request, user *store.User, t store.Task) string { + return ics.Render(ics.Calendar{ + Name: "verdande", + Domain: feedDomain(s.cfg.BaseURL), + Tasks: []ics.Task{{ + ID: t.ID, Content: t.Content, Description: t.Description, + DueDate: t.DueDate, DueDatetime: t.DueDatetime, DurationMin: t.DurationMin, + Priority: t.Priority, Recurrence: t.RecurrenceRule, + Completed: t.Completed(), CompletedAt: t.CompletedAt, + CreatedAt: t.CreatedAt, UpdatedAt: t.UpdatedAt, + }}, + }) +} + +// projectCTag changes whenever anything in the project does, which is what tells a +// polling client to re-sync. The newest update time plus the row count catches both +// an edit and a deletion — an edit alone would miss a task being removed. +func (s *Server) projectCTag(r *http.Request, projectID string) (string, error) { + var newest, count int64 + err := s.db.QueryRowContext(r.Context(), + `SELECT COALESCE(max(updated_at), 0), count(*) FROM tasks WHERE project_id = ?`, + projectID).Scan(&newest, &count) + if err != nil { + return "", err + } + return fmt.Sprintf("%d-%d", newest, count), nil +} + +// taskETag identifies one version of one task. A client compares it to decide +// whether to fetch the body again. +func taskETag(t store.Task) string { + return fmt.Sprintf("%d", t.UpdatedAt.Unix()) +} + +func taskHref(userID, projectID, taskID string) string { + return "/caldav/" + userID + "/" + projectID + "/" + taskID + ".ics" +} + +func hrefTaskID(href string) string { + parts := strings.Split(strings.TrimSuffix(href, ".ics"), "/") + if len(parts) == 0 { + return "" + } + return parts[len(parts)-1] +} + +func writeMultistatus(w http.ResponseWriter, m *caldav.Multistatus) { + w.Header().Set("Content-Type", "application/xml; charset=utf-8") + w.Header().Set("DAV", "1, 2, 3, calendar-access") + w.WriteHeader(http.StatusMultiStatus) + w.Write([]byte(m.XML())) +} + +// caldavRoutes mounts the whole thing. Registered outside /api/v1 because CalDAV +// clients do well-known-path discovery against the root. +func (s *Server) caldavRoutes(r chi.Router) { + r.Use(s.caldavAuth) + + r.Method(http.MethodOptions, "/*", http.HandlerFunc(s.handleCalDAVOptions)) + + r.Method("PROPFIND", "/", http.HandlerFunc(s.handleCalDAVPrincipal)) + r.Method("PROPFIND", "/{userID}/", http.HandlerFunc(s.handleCalDAVHome)) + r.Method("PROPFIND", "/{userID}/{projectID}/", http.HandlerFunc(s.handleCalDAVCalendar)) + r.Method("REPORT", "/{userID}/{projectID}/", http.HandlerFunc(s.handleCalDAVReport)) + + r.Get("/{userID}/{projectID}/{taskFile}", s.handleCalDAVGet) + r.Put("/{userID}/{projectID}/{taskFile}", s.handleCalDAVPut) + r.Delete("/{userID}/{projectID}/{taskFile}", s.handleCalDAVDelete) +} + +// wellKnownCalDAV is where clients look first. Apple Reminders asks for +// /.well-known/caldav on the bare hostname before it will try anything else. +func (s *Server) wellKnownCalDAV(w http.ResponseWriter, r *http.Request) { + http.Redirect(w, r, "/caldav/", http.StatusMovedPermanently) +} + +var _ = time.Now diff --git a/internal/httpapi/filters_handlers.go b/internal/httpapi/filters_handlers.go index 1c4bc8e..c70e2d1 100644 --- a/internal/httpapi/filters_handlers.go +++ b/internal/httpapi/filters_handlers.go @@ -181,6 +181,12 @@ func filterContext(user *store.User) filterquery.Context { } } +// parseFilter compiles an expression for a user. Shared with the MCP tools, so a +// filter written in Claude means exactly what the same filter means in the app. +func parseFilter(query string, user *store.User) (filterquery.Compiled, error) { + return filterquery.Parse(query, filterContext(user)) +} + func checkFilterQuery(query string, user *store.User) string { if strings.TrimSpace(query) == "" { return "required" diff --git a/internal/httpapi/integrations_handlers.go b/internal/httpapi/integrations_handlers.go new file mode 100644 index 0000000..655897d --- /dev/null +++ b/internal/httpapi/integrations_handlers.go @@ -0,0 +1,401 @@ +package httpapi + +import ( + "encoding/json" + "net/http" + "strings" + "time" + + "github.com/go-chi/chi/v5" + + "github.com/kristianwind/verdande/internal/ai" + "github.com/kristianwind/verdande/internal/auth" + "github.com/kristianwind/verdande/internal/quickadd" + "github.com/kristianwind/verdande/internal/store" +) + +// --- mail to task ------------------------------------------------------------------- + +type mailAddressResponse struct { + Address string `json:"address"` +} + +// handleGetMailAddress returns the personal address that turns an email into a +// task, minting the token on first ask. +// +// The token goes in the local part rather than the domain — todo+@domain — +// because that is what a single Mailcow alias can route without a wildcard domain +// or a DNS change per user. +func (s *Server) handleGetMailAddress(w http.ResponseWriter, r *http.Request) { + user := userFrom(r.Context()) + + token, err := s.db.EnsureMailToken(r.Context(), user.ID) + if err != nil { + s.internal(w, "mail token", err) + return + } + writeJSON(w, http.StatusOK, mailAddressResponse{Address: s.mailAddress(token)}) +} + +func (s *Server) handleRotateMailAddress(w http.ResponseWriter, r *http.Request) { + token, err := auth.NewToken() + if err != nil { + s.internal(w, "generate mail token", err) + return + } + if err := s.db.SetMailToken(r.Context(), userFrom(r.Context()).ID, token); err != nil { + s.internal(w, "set mail token", err) + return + } + writeJSON(w, http.StatusOK, mailAddressResponse{Address: s.mailAddress(token)}) +} + +func (s *Server) mailAddress(token string) string { + domain := feedDomain(s.cfg.BaseURL) + if from := s.cfg.SMTP.From; strings.Contains(from, "@") { + // The mail server's own domain, which is the one that can actually route + // this — the web address may well be a tunnel hostname that receives no mail. + domain = from[strings.Index(from, "@")+1:] + } + return "todo+" + token + "@" + domain +} + +type inboundMailRequest struct { + // To is the address the mail was sent to, from which the token is read. + To string `json:"to"` + From string `json:"from"` + Subject string `json:"subject"` + Body string `json:"body"` +} + +// handleInboundMail turns a delivered email into a task. +// +// Mailcow delivers to this endpoint over LMTP or a small forwarding script; either +// way what arrives here is already-parsed mail. Authentication is the token in the +// recipient address, which is the only credential an inbound mail can carry. +// +// The subject is parsed with the quick-add parser, so "Fakturer Anders p1 #Firma" +// in a subject line does what it does everywhere else in the app. +func (s *Server) handleInboundMail(w http.ResponseWriter, r *http.Request) { + var req inboundMailRequest + if err := decodeJSON(w, r, &req); err != nil { + return + } + + token := mailToken(req.To) + if token == "" { + writeError(w, http.StatusNotFound, CodeNotFound, "no such address") + return + } + user, err := s.db.UserByMailToken(r.Context(), token) + if err != nil { + // Deliberately identical to a malformed address: an inbound endpoint that + // distinguishes them is an oracle for which tokens are live. + writeError(w, http.StatusNotFound, CodeNotFound, "no such address") + return + } + + subject := strings.TrimSpace(req.Subject) + if subject == "" { + subject = "(uden emne)" + } + + parsed := quickadd.Parse(subject, time.Now().In(userLocation(user.Timezone)), user.Locale) + content := parsed.Content + if strings.TrimSpace(content) == "" { + content = subject + } + + projectID := "" + if parsed.Project != "" { + if id, err := s.db.ProjectByName(r.Context(), user.ID, parsed.Project); err == nil { + projectID = id + } + } + if projectID == "" { + if projectID, err = s.db.InboxID(r.Context(), user.ID); err != nil { + s.internal(w, "inbox", err) + return + } + } + + task := &store.Task{ + ProjectID: projectID, Content: content, Priority: parsed.Priority, + Description: strings.TrimSpace(req.Body), DueDate: parsed.DueDate, + RecurrenceRule: parsed.Recurrence, CreatedBy: user.ID, + } + if err := s.db.CreateTask(r.Context(), task, parsed.Labels); err != nil { + s.internal(w, "create task from mail", err) + return + } + + s.publish(projectID, "task.created", toTaskJSON(*task)) + s.log.Info("task created from mail", "user", user.ID, "from", req.From) + writeJSON(w, http.StatusCreated, map[string]string{"task_id": task.ID}) +} + +// mailToken reads the token out of "Name ". +func mailToken(address string) string { + if i := strings.LastIndex(address, "<"); i >= 0 { + address = address[i+1:] + } + address = strings.TrimSuffix(strings.TrimSpace(address), ">") + + local, _, ok := strings.Cut(address, "@") + if !ok { + return "" + } + _, token, ok := strings.Cut(local, "+") + if !ok { + return "" + } + return token +} + +// --- AI --------------------------------------------------------------------------------- + +type aiSettings struct { + Provider string `json:"provider"` + BaseURL string `json:"base_url"` + Model string `json:"model"` + // APIKey is written but never read back — see handleGetAISettings. + APIKey string `json:"api_key,omitempty"` + // HasKey tells the settings page whether a key is stored, without showing it. + HasKey bool `json:"has_key"` +} + +func (s *Server) handleGetAISettings(w http.ResponseWriter, r *http.Request) { + cfg, err := s.aiConfig(r, userFrom(r.Context()).ID) + if err != nil { + s.internal(w, "ai settings", err) + return + } + // The key is never sent back. A settings page that repopulates a password + // field is a settings page that will eventually leak one into a screenshot. + writeJSON(w, http.StatusOK, aiSettings{ + Provider: string(cfg.Provider), BaseURL: cfg.BaseURL, + Model: cfg.Model, HasKey: cfg.APIKey != "", + }) +} + +func (s *Server) handleSetAISettings(w http.ResponseWriter, r *http.Request) { + var req aiSettings + if err := decodeJSON(w, r, &req); err != nil { + return + } + user := userFrom(r.Context()) + + switch ai.Provider(req.Provider) { + case ai.ProviderAnthropic, ai.ProviderOpenAI, ai.ProviderGoogle, ai.ProviderCompatible, "": + default: + writeFieldErrors(w, map[string]string{ + "provider": "must be anthropic, openai, google or compatible", + }) + return + } + + existing, err := s.aiConfig(r, user.ID) + if err != nil { + s.internal(w, "ai settings", err) + return + } + // An empty key means "leave it alone", not "delete it" — otherwise saving a + // changed model would silently clear the key. + key := existing.APIKey + if req.APIKey != "" { + key = req.APIKey + } + + settings := map[string]any{ + "provider": req.Provider, "base_url": req.BaseURL, + "model": req.Model, "api_key": key, + } + if err := s.db.SetUserSettings(r.Context(), user.ID, "ai", settings); err != nil { + s.internal(w, "save ai settings", err) + return + } + w.WriteHeader(http.StatusNoContent) +} + +func (s *Server) aiConfig(r *http.Request, userID string) (ai.Config, error) { + values, err := s.db.UserSettings(r.Context(), userID, "ai") + if err != nil { + return ai.Config{}, err + } + str := func(key string) string { + v, _ := values[key].(string) + return v + } + return ai.Config{ + Provider: ai.Provider(str("provider")), + APIKey: str("api_key"), + BaseURL: str("base_url"), + Model: str("model"), + }, nil +} + +// handleAISplit breaks a task into sub-tasks and creates them. +func (s *Server) handleAISplit(w http.ResponseWriter, r *http.Request) { + user := userFrom(r.Context()) + taskID := chi.URLParam(r, "taskID") + + role, err := store.TaskRole(r.Context(), s.db, taskID, user.ID) + if err != nil || !role.CanEdit() { + writeError(w, http.StatusNotFound, CodeNotFound, "not found") + return + } + cfg, err := s.aiConfig(r, user.ID) + if err != nil { + s.internal(w, "ai settings", err) + return + } + if !cfg.Configured() { + // Not an error the user did anything to cause: the feature is simply off. + writeError(w, http.StatusConflict, CodeConflict, + "der er ikke sat en AI-udbyder op under indstillinger") + return + } + + task, err := s.db.GetTask(r.Context(), taskID, user.ID) + if err != nil { + s.storeError(w, "get task", err) + return + } + + ctx, cancel := contextWithTimeout(r, 90*time.Second) + defer cancel() + + subtasks, err := ai.New(cfg).SplitIntoSubtasks(ctx, task.Content, task.Description, user.Locale) + if err != nil { + s.log.Warn("ai split", "err", err) + writeError(w, http.StatusBadGateway, CodeInternal, err.Error()) + return + } + + created := make([]taskJSON, 0, len(subtasks)) + for _, content := range subtasks { + child := &store.Task{ + ProjectID: task.ProjectID, ParentID: task.ID, Content: content, + Priority: task.Priority, CreatedBy: user.ID, + } + if err := s.db.CreateTask(r.Context(), child, nil); err != nil { + s.internal(w, "create subtask", err) + return + } + created = append(created, toTaskJSON(*child)) + } + + s.activity(r, task.ProjectID, task.ID, "task.split", map[string]any{"count": len(created)}) + writeJSON(w, http.StatusCreated, map[string]any{"subtasks": created}) +} + +// handleAISummary writes a short prioritisation note over what is outstanding. +func (s *Server) handleAISummary(w http.ResponseWriter, r *http.Request) { + user := userFrom(r.Context()) + + cfg, err := s.aiConfig(r, user.ID) + if err != nil { + s.internal(w, "ai settings", err) + return + } + if !cfg.Configured() { + writeError(w, http.StatusConflict, CodeConflict, + "der er ikke sat en AI-udbyder op under indstillinger") + return + } + + tasks, err := s.db.ListTasks(r.Context(), user.ID, store.TaskFilter{Limit: 100}) + if err != nil { + s.internal(w, "list tasks", err) + return + } + if len(tasks) == 0 { + writeJSON(w, http.StatusOK, map[string]string{"summary": "Der er ingen åbne opgaver."}) + return + } + + lines := make([]string, 0, len(tasks)) + for _, t := range tasks { + line := "- " + t.Content + if t.DueDate != "" { + line += " (forfalder " + t.DueDate + ")" + } + if t.Priority < 4 { + line += " [P" + string(rune('0'+t.Priority)) + "]" + } + lines = append(lines, line) + } + + ctx, cancel := contextWithTimeout(r, 90*time.Second) + defer cancel() + + summary, err := ai.New(cfg).WeeklySummary(ctx, lines, user.Locale) + if err != nil { + s.log.Warn("ai summary", "err", err) + writeError(w, http.StatusBadGateway, CodeInternal, err.Error()) + return + } + writeJSON(w, http.StatusOK, map[string]string{"summary": summary}) +} + +// --- Gmail ---------------------------------------------------------------------------- + +type gmailSettings struct { + Connected bool `json:"connected"` + Email string `json:"email,omitempty"` + // Trigger is what causes a task to be created: a starred message, a label, or + // both. One-way by design — unstarring does nothing to the task. + Trigger string `json:"trigger"` + Label string `json:"label,omitempty"` +} + +func (s *Server) handleGetGmail(w http.ResponseWriter, r *http.Request) { + values, err := s.db.UserSettings(r.Context(), userFrom(r.Context()).ID, "gmail") + if err != nil { + s.internal(w, "gmail settings", err) + return + } + str := func(key string) string { + v, _ := values[key].(string) + return v + } + writeJSON(w, http.StatusOK, gmailSettings{ + Connected: str("refresh_token") != "", + Email: str("email"), + Trigger: str("trigger"), + Label: str("label"), + }) +} + +func (s *Server) handleSetGmail(w http.ResponseWriter, r *http.Request) { + var req gmailSettings + if err := decodeJSON(w, r, &req); err != nil { + return + } + user := userFrom(r.Context()) + + values, err := s.db.UserSettings(r.Context(), user.ID, "gmail") + if err != nil { + s.internal(w, "gmail settings", err) + return + } + values["trigger"] = req.Trigger + values["label"] = req.Label + + if err := s.db.SetUserSettings(r.Context(), user.ID, "gmail", values); err != nil { + s.internal(w, "save gmail settings", err) + return + } + w.WriteHeader(http.StatusNoContent) +} + +// handleDisconnectGmail forgets the connection, tokens and all. +func (s *Server) handleDisconnectGmail(w http.ResponseWriter, r *http.Request) { + if err := s.db.SetUserSettings(r.Context(), userFrom(r.Context()).ID, "gmail", map[string]any{}); err != nil { + s.internal(w, "disconnect gmail", err) + return + } + w.WriteHeader(http.StatusNoContent) +} + +var _ = json.Marshal diff --git a/internal/httpapi/integrations_test.go b/internal/httpapi/integrations_test.go new file mode 100644 index 0000000..c9fc5cc --- /dev/null +++ b/internal/httpapi/integrations_test.go @@ -0,0 +1,584 @@ +package httpapi + +import ( + "encoding/json" + "io" + "net/http" + "strings" + "testing" +) + +// --- MCP ----------------------------------------------------------------------------- + +// rpc sends one JSON-RPC message to the MCP endpoint. +func (ts *testServer) rpc(t *testing.T, method string, params any) map[string]any { + t.Helper() + + body := map[string]any{"jsonrpc": "2.0", "id": 1, "method": method} + if params != nil { + body["params"] = params + } + _, decoded := ts.do(t, "POST", "/api/v1/mcp", body) + return decoded +} + +func TestMCPHandshake(t *testing.T) { + ts := newTestServer(t) + ts.bootstrap(t) + + init := ts.rpc(t, "initialize", map[string]any{ + "protocolVersion": "2024-11-05", + "clientInfo": map[string]any{"name": "claude", "version": "1"}, + }) + result, _ := init["result"].(map[string]any) + if result == nil { + t.Fatalf("initialize returned no result: %v", init) + } + if result["protocolVersion"] == nil { + t.Error("no protocol version was announced") + } + caps, _ := result["capabilities"].(map[string]any) + if _, ok := caps["tools"]; !ok { + t.Errorf("tools were not announced as a capability: %v", caps) + } + + listed := ts.rpc(t, "tools/list", nil) + result, _ = listed["result"].(map[string]any) + tools, _ := result["tools"].([]any) + if len(tools) == 0 { + t.Fatal("no tools were listed") + } + + // The tools the brief names have to be there, each with a schema a model can + // actually call from. + names := map[string]bool{} + for _, raw := range tools { + tool := raw.(map[string]any) + name := tool["name"].(string) + names[name] = true + + if tool["description"] == "" { + t.Errorf("tool %q has no description", name) + } + if _, ok := tool["inputSchema"].(map[string]any); !ok { + t.Errorf("tool %q has no input schema", name) + } + } + for _, want := range []string{ + "search_tasks", "create_task", "update_task", "complete_task", + "list_projects", "add_comment", + } { + if !names[want] { + t.Errorf("the %q tool is missing", want) + } + } +} + +// A notification has no id, and the spec says to answer with nothing at all. +func TestMCPNotificationGetsNoResponse(t *testing.T) { + ts := newTestServer(t) + ts.bootstrap(t) + + resp, _ := ts.do(t, "POST", "/api/v1/mcp", map[string]any{ + "jsonrpc": "2.0", "method": "notifications/initialized", + }) + if resp.StatusCode != http.StatusAccepted { + t.Errorf("a notification got status %d, want 202 with no body", resp.StatusCode) + } +} + +func TestMCPToolsDoTheWork(t *testing.T) { + ts := newTestServer(t) + ts.bootstrap(t) + + // create_task, through the natural-language path a model is told to prefer. + created := ts.callTool(t, "create_task", map[string]any{ + "text": "betal moms i morgen kl 10 p1", + }) + if created["id"] == nil { + t.Fatalf("create_task returned no id: %v", created) + } + if created["priority"] != float64(1) { + t.Errorf("priority = %v, want 1 — the text was not parsed", created["priority"]) + } + taskID := created["id"].(string) + + // search_tasks, with the filter language. + found := ts.callTool(t, "search_tasks", map[string]any{"query": "p1"}) + tasks, _ := found["tasks"].([]any) + if len(tasks) != 1 { + t.Fatalf("search_tasks found %d tasks, want 1", len(tasks)) + } + + // list_projects + projects := ts.callTool(t, "list_projects", map[string]any{}) + if got, _ := projects["projects"].([]any); len(got) == 0 { + t.Error("list_projects returned nothing") + } + + // update_task + updated := ts.callTool(t, "update_task", map[string]any{ + "task_id": taskID, "priority": 3, + }) + if updated["priority"] != float64(3) { + t.Errorf("priority = %v after update, want 3", updated["priority"]) + } + + // add_comment + comment := ts.callTool(t, "add_comment", map[string]any{ + "task_id": taskID, "body": "sagt af Claude", + }) + if comment["id"] == nil { + t.Errorf("add_comment returned no id: %v", comment) + } + + // complete_task + done := ts.callTool(t, "complete_task", map[string]any{"task_id": taskID}) + if done["completed"] != true { + t.Errorf("complete_task did not complete: %v", done) + } +} + +// A tool called wrongly must produce a readable tool error, not a protocol error — +// a model can recover from the first and can do nothing with the second. +func TestMCPToolErrorsAreReadable(t *testing.T) { + ts := newTestServer(t) + ts.bootstrap(t) + + response := ts.rpc(t, "tools/call", map[string]any{ + "name": "complete_task", + "arguments": map[string]any{"task_id": "findes-ikke"}, + }) + + if response["error"] != nil { + t.Errorf("a failed tool call produced a JSON-RPC error rather than a tool result: %v", response["error"]) + } + result, _ := response["result"].(map[string]any) + if result["isError"] != true { + t.Fatalf("the result is not marked as an error: %v", result) + } + content, _ := result["content"].([]any) + if len(content) == 0 { + t.Fatal("the error has no content for the model to read") + } + text, _ := content[0].(map[string]any)["text"].(string) + if !strings.Contains(text, "findes-ikke") { + t.Errorf("the message does not say what was wrong: %q", text) + } +} + +// An MCP connector reaches exactly what its owner reaches, and nothing else. +func TestMCPCannotReachAnotherUsersTasks(t *testing.T) { + ts := newTestServer(t) + ts.bootstrap(t) + other := ts.newUser(t, "anden@example.dk", "Anden") + + ts.do(t, "POST", "/api/v1/tasks", map[string]any{"content": "min private opgave"}) + + found := other.callTool(t, "search_tasks", map[string]any{"text": "private"}) + if tasks, _ := found["tasks"].([]any); len(tasks) != 0 { + t.Errorf("another user's MCP session found %d of the owner's tasks", len(tasks)) + } +} + +// callTool runs one tool and returns its decoded result. +func (ts *testServer) callTool(t *testing.T, name string, args map[string]any) map[string]any { + t.Helper() + + response := ts.rpc(t, "tools/call", map[string]any{"name": name, "arguments": args}) + if response["error"] != nil { + t.Fatalf("%s: protocol error %v", name, response["error"]) + } + result, _ := response["result"].(map[string]any) + if result == nil { + t.Fatalf("%s: no result", name) + } + if result["isError"] == true { + content, _ := result["content"].([]any) + text := "" + if len(content) > 0 { + text, _ = content[0].(map[string]any)["text"].(string) + } + t.Fatalf("%s failed: %s", name, text) + } + + content, _ := result["content"].([]any) + if len(content) == 0 { + t.Fatalf("%s: no content", name) + } + text, _ := content[0].(map[string]any)["text"].(string) + + var decoded map[string]any + if err := json.Unmarshal([]byte(text), &decoded); err != nil { + t.Fatalf("%s: the result is not JSON: %v\n%s", name, err, text) + } + return decoded +} + +// --- CalDAV --------------------------------------------------------------------------- + +// dav sends a WebDAV request with Basic auth, as a real client would. +func (ts *testServer) dav(t *testing.T, method, path, token, email, body string) *http.Response { + t.Helper() + + req, err := http.NewRequest(method, ts.URL+path, strings.NewReader(body)) + if err != nil { + t.Fatal(err) + } + req.SetBasicAuth(email, token) + if body != "" { + req.Header.Set("Content-Type", "application/xml") + } + resp, err := (&http.Client{}).Do(req) + if err != nil { + t.Fatalf("%s %s: %v", method, path, err) + } + t.Cleanup(func() { resp.Body.Close() }) + return resp +} + +// apiToken mints a personal token, which is what CalDAV clients authenticate with. +func (ts *testServer) apiToken(t *testing.T, userID string) string { + t.Helper() + token, _, err := ts.db.CreateAPIToken(t.Context(), userID, "caldav", nil) + if err != nil { + t.Fatal(err) + } + return token +} + +func TestCalDAVRequiresCredentials(t *testing.T) { + ts := newTestServer(t) + ts.bootstrap(t) + + resp, err := (&http.Client{}).Get(ts.URL + "/caldav/") + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusUnauthorized { + t.Errorf("status %d, want 401", resp.StatusCode) + } + // Without a challenge, a client has nothing to prompt with. + if resp.Header.Get("WWW-Authenticate") == "" { + t.Error("no WWW-Authenticate header was sent") + } +} + +// The account password must not work here — a client stores what it is given, and +// what it is given should not be able to sign in to the app. +func TestCalDAVRejectsTheAccountPassword(t *testing.T) { + ts := newTestServer(t) + ts.bootstrap(t) + + resp := ts.dav(t, "PROPFIND", "/caldav/", "syv heste over marken", "kristian@example.dk", "") + if resp.StatusCode != http.StatusUnauthorized { + t.Errorf("the account password was accepted: status %d", resp.StatusCode) + } +} + +func TestCalDAVDiscovery(t *testing.T) { + ts := newTestServer(t) + ts.bootstrap(t) + + user, err := ts.db.UserByEmail(t.Context(), "kristian@example.dk") + if err != nil { + t.Fatal(err) + } + token := ts.apiToken(t, user.ID) + + // OPTIONS is what a client reads first. Without calendar-access in the DAV + // header, Apple Reminders decides this is plain WebDAV and stops. + options := ts.dav(t, "OPTIONS", "/caldav/", token, user.Email, "") + if dav := options.Header.Get("DAV"); !strings.Contains(dav, "calendar-access") { + t.Errorf("DAV header = %q, want calendar-access in it", dav) + } + + principal := ts.dav(t, "PROPFIND", "/caldav/", token, user.Email, "") + if principal.StatusCode != http.StatusMultiStatus { + t.Fatalf("PROPFIND on the root: status %d, want 207", principal.StatusCode) + } + body, _ := io.ReadAll(principal.Body) + if !strings.Contains(string(body), "calendar-home-set") { + t.Errorf("the principal response does not point at a calendar home:\n%s", body) + } + + // The home lists one collection per project. + home := ts.dav(t, "PROPFIND", "/caldav/"+user.ID+"/", token, user.Email, "") + if home.StatusCode != http.StatusMultiStatus { + t.Fatalf("PROPFIND on the home: status %d", home.StatusCode) + } + homeBody, _ := io.ReadAll(home.Body) + if !strings.Contains(string(homeBody), "") { + t.Errorf("the collections do not declare VTODO support:\n%s", homeBody) + } + if !strings.Contains(string(homeBody), "getctag") { + t.Error("no ctag was returned; clients would re-sync everything every time") + } +} + +// The half that makes CalDAV two-way: a client creating and completing a task. +func TestCalDAVReadWrite(t *testing.T) { + ts := newTestServer(t) + ts.bootstrap(t) + + user, err := ts.db.UserByEmail(t.Context(), "kristian@example.dk") + if err != nil { + t.Fatal(err) + } + token := ts.apiToken(t, user.ID) + inbox, err := ts.db.InboxID(t.Context(), user.ID) + if err != nil { + t.Fatal(err) + } + + // A client PUTs a VTODO it made up an id for. + vtodo := "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nBEGIN:VTODO\r\n" + + "UID:fra-reminders\r\nSUMMARY:Købt af Apple Reminders\r\n" + + "DUE;VALUE=DATE:20260315\r\nPRIORITY:1\r\nSTATUS:NEEDS-ACTION\r\n" + + "END:VTODO\r\nEND:VCALENDAR\r\n" + + put := ts.dav(t, "PUT", "/caldav/"+user.ID+"/"+inbox+"/fra-reminders.ics", + token, user.Email, vtodo) + if put.StatusCode != http.StatusCreated { + body, _ := io.ReadAll(put.Body) + t.Fatalf("PUT: status %d, %s", put.StatusCode, body) + } + + // It is a real task now. + task, err := ts.db.GetTask(t.Context(), "fra-reminders", user.ID) + if err != nil { + t.Fatalf("the task was not created: %v", err) + } + if task.Content != "Købt af Apple Reminders" { + t.Errorf("content = %q", task.Content) + } + if task.DueDate != "2026-03-15" { + t.Errorf("due_date = %q", task.DueDate) + } + if task.Priority != 1 { + t.Errorf("priority = %d, want 1 (iCalendar's 1)", task.Priority) + } + + // And it reads back as a VTODO. + get := ts.dav(t, "GET", "/caldav/"+user.ID+"/"+inbox+"/fra-reminders.ics", token, user.Email, "") + if get.StatusCode != http.StatusOK { + t.Fatalf("GET: status %d", get.StatusCode) + } + body, _ := io.ReadAll(get.Body) + if !strings.Contains(string(body), "BEGIN:VTODO") { + t.Errorf("the response is not a VTODO:\n%s", body) + } + if !strings.Contains(string(body), "SUMMARY:Købt af Apple Reminders") { + t.Errorf("the summary is missing:\n%s", body) + } + + // Ticking it off in the client arrives as a PUT with STATUS:COMPLETED. + completed := strings.Replace(vtodo, "STATUS:NEEDS-ACTION", "STATUS:COMPLETED", 1) + done := ts.dav(t, "PUT", "/caldav/"+user.ID+"/"+inbox+"/fra-reminders.ics", + token, user.Email, completed) + if done.StatusCode != http.StatusNoContent { + t.Fatalf("PUT to complete: status %d", done.StatusCode) + } + + after, err := ts.db.GetTask(t.Context(), "fra-reminders", user.ID) + if err != nil { + t.Fatal(err) + } + if !after.Completed() { + t.Error("completing the task in a CalDAV client did not complete it here") + } + + // A task completed in the app must stay visible to the client, or the client + // treats it as deleted and drops its own record of having done it. + report := ts.dav(t, "REPORT", "/caldav/"+user.ID+"/"+inbox+"/", token, user.Email, + ``+ + ``) + if report.StatusCode != http.StatusMultiStatus { + t.Fatalf("REPORT: status %d", report.StatusCode) + } + reportBody, _ := io.ReadAll(report.Body) + if !strings.Contains(string(reportBody), "fra-reminders") { + t.Errorf("a completed task vanished from the collection:\n%s", reportBody) + } +} + +// --- mail to task ------------------------------------------------------------------------- + +func TestMailToTask(t *testing.T) { + ts := newTestServer(t) + ts.bootstrap(t) + + _, project := ts.do(t, "POST", "/api/v1/projects", map[string]any{"name": "Firma"}) + projectID := project["id"].(string) + + resp, body := ts.do(t, "GET", "/api/v1/mail-address", nil) + if resp.StatusCode != http.StatusOK { + t.Fatalf("mail address: %d %v", resp.StatusCode, body) + } + address, _ := body["address"].(string) + if !strings.HasPrefix(address, "todo+") { + t.Fatalf("address = %q, want todo+@domain", address) + } + + // Delivered mail. The subject goes through the quick-add parser. + resp, created := ts.do(t, "POST", "/inbound/mail", map[string]any{ + "to": address, + "from": "revisor@example.dk", + "subject": "Send årsregnskab p1 #Firma", + "body": "Vedhæftet er sidste års tal.", + }) + if resp.StatusCode != http.StatusCreated { + t.Fatalf("inbound mail: %d %v", resp.StatusCode, created) + } + + _, list := ts.do(t, "GET", "/api/v1/tasks?project_id="+projectID, nil) + tasks, _ := list["tasks"].([]any) + if len(tasks) != 1 { + t.Fatalf("got %d tasks in #Firma, want the one from the mail", len(tasks)) + } + task := tasks[0].(map[string]any) + if task["content"] != "Send årsregnskab" { + t.Errorf("content = %v", task["content"]) + } + if task["priority"] != float64(1) { + t.Errorf("priority = %v, want 1", task["priority"]) + } + if task["description"] != "Vedhæftet er sidste års tal." { + t.Errorf("the body did not become the description: %v", task["description"]) + } +} + +// An invented address must not create anything, and must not say whether the token +// merely does not exist — that would be an oracle for which addresses are live. +func TestInboundMailToAnUnknownAddress(t *testing.T) { + ts := newTestServer(t) + ts.bootstrap(t) + + resp, _ := ts.do(t, "POST", "/inbound/mail", map[string]any{ + "to": "todo+opfundet@example.dk", "subject": "hej", "body": "", + }) + if resp.StatusCode != http.StatusNotFound { + t.Errorf("status %d, want 404", resp.StatusCode) + } + + malformed, _ := ts.do(t, "POST", "/inbound/mail", map[string]any{ + "to": "ikke-en-adresse", "subject": "hej", "body": "", + }) + if malformed.StatusCode != resp.StatusCode { + t.Errorf("a malformed address answers %d and an unknown one %d; they must match", + malformed.StatusCode, resp.StatusCode) + } +} + +func TestRotatingTheMailAddress(t *testing.T) { + ts := newTestServer(t) + ts.bootstrap(t) + + _, first := ts.do(t, "GET", "/api/v1/mail-address", nil) + old := first["address"].(string) + + _, second := ts.do(t, "POST", "/api/v1/mail-address/rotate", nil) + fresh := second["address"].(string) + + if old == fresh { + t.Fatal("rotating produced the same address") + } + resp, _ := ts.do(t, "POST", "/inbound/mail", map[string]any{ + "to": old, "subject": "til den gamle adresse", "body": "", + }) + if resp.StatusCode != http.StatusNotFound { + t.Errorf("the old address still works: status %d", resp.StatusCode) + } +} + +// --- AI --------------------------------------------------------------------------------- + +// With no provider configured the AI features are off, not broken. That is the +// promise: the app works without them. +func TestAIDegradesWhenNotConfigured(t *testing.T) { + ts := newTestServer(t) + ts.bootstrap(t) + + resp, body := ts.do(t, "GET", "/api/v1/ai/settings", nil) + if resp.StatusCode != http.StatusOK { + t.Fatalf("ai settings: %d %v", resp.StatusCode, body) + } + if body["has_key"] != false { + t.Errorf("has_key = %v on a fresh instance", body["has_key"]) + } + + _, task := ts.do(t, "POST", "/api/v1/tasks", map[string]any{"content": "flyt"}) + resp, _ = ts.do(t, "POST", "/api/v1/ai/tasks/"+task["id"].(string)+"/split", nil) + if resp.StatusCode != http.StatusConflict { + t.Errorf("split with no provider: status %d, want 409", resp.StatusCode) + } + + resp, _ = ts.do(t, "POST", "/api/v1/ai/summary", nil) + if resp.StatusCode != http.StatusConflict { + t.Errorf("summary with no provider: status %d, want 409", resp.StatusCode) + } +} + +// The API key is stored but never handed back — a settings page that repopulates a +// password field is one that will eventually leak it into a screenshot. +func TestAIKeyIsNeverReturned(t *testing.T) { + ts := newTestServer(t) + ts.bootstrap(t) + + resp, _ := ts.do(t, "PUT", "/api/v1/ai/settings", map[string]any{ + "provider": "anthropic", "model": "claude-sonnet-5", "api_key": "sk-hemmelig-nøgle", + }) + if resp.StatusCode != http.StatusNoContent { + t.Fatalf("save settings: status %d", resp.StatusCode) + } + + _, body := ts.do(t, "GET", "/api/v1/ai/settings", nil) + if body["has_key"] != true { + t.Error("has_key is false after saving a key") + } + if body["api_key"] != nil && body["api_key"] != "" { + t.Errorf("the key was returned: %v", body["api_key"]) + } + if body["model"] != "claude-sonnet-5" { + t.Errorf("model = %v", body["model"]) + } + + // Saving again without a key must keep the stored one, or changing the model + // would silently disconnect the provider. + ts.do(t, "PUT", "/api/v1/ai/settings", map[string]any{ + "provider": "anthropic", "model": "claude-opus-5", + }) + _, after := ts.do(t, "GET", "/api/v1/ai/settings", nil) + if after["has_key"] != true { + t.Error("the stored key was cleared by a save that did not include one") + } + if after["model"] != "claude-opus-5" { + t.Errorf("model = %v after update", after["model"]) + } +} + +func TestGmailSettings(t *testing.T) { + ts := newTestServer(t) + ts.bootstrap(t) + + resp, body := ts.do(t, "GET", "/api/v1/gmail", nil) + if resp.StatusCode != http.StatusOK { + t.Fatalf("gmail settings: %d %v", resp.StatusCode, body) + } + if body["connected"] != false { + t.Error("a fresh instance reports Gmail as connected") + } + + resp, _ = ts.do(t, "PUT", "/api/v1/gmail", map[string]any{ + "trigger": "starred", "label": "Til handling", + }) + if resp.StatusCode != http.StatusNoContent { + t.Fatalf("save: status %d", resp.StatusCode) + } + _, after := ts.do(t, "GET", "/api/v1/gmail", nil) + if after["trigger"] != "starred" || after["label"] != "Til handling" { + t.Errorf("settings did not persist: %v", after) + } +} diff --git a/internal/httpapi/mcp_handlers.go b/internal/httpapi/mcp_handlers.go new file mode 100644 index 0000000..88449cb --- /dev/null +++ b/internal/httpapi/mcp_handlers.go @@ -0,0 +1,413 @@ +package httpapi + +import ( + "context" + "encoding/json" + "io" + "net/http" + "strings" + "time" + + "github.com/kristianwind/verdande/internal/mcp" + "github.com/kristianwind/verdande/internal/quickadd" + "github.com/kristianwind/verdande/internal/store" +) + +// buildMCP registers the tools verdande exposes to Claude. +// +// The descriptions are written for a model to read. That means saying *when* to +// reach for a tool, not only what it does — a model choosing between +// `search_tasks` and `list_projects` is answering "which of these gets me closer", +// and a description that only names the endpoint gives it nothing to decide with. +func (s *Server) buildMCP() *mcp.Server { + m := mcp.NewServer("verdande", "1") + + m.Register(mcp.Tool{ + Name: "list_projects", + Description: "List the user's projects, including which are shared and how many people " + + "can see them. Use this first when the user names a project, to turn that name into an id.", + InputSchema: mcp.Schema(map[string]any{ + "include_archived": mcp.Bool("Include archived projects. Defaults to false."), + }), + }, s.mcpListProjects) + + m.Register(mcp.Tool{ + Name: "search_tasks", + Description: "Find tasks by text, project, label, priority, due date or assignee. " + + "This is the tool for any question about what the user has to do — " + + "\"what is due today\", \"what is overdue\", \"anything about the tax return\". " + + "The query accepts the same filter language as the app: \"today & p1\", " + + "\"overdue\", \"#Firma & @regnskab\", \"7 days\", \"assigned to: me\".", + InputSchema: mcp.Schema(map[string]any{ + "query": mcp.Str("A filter expression, e.g. \"today & p1\" or \"overdue\"."), + "text": mcp.Str("Free text to search task titles and descriptions for."), + "limit": mcp.Int("How many tasks to return. Defaults to 50, maximum 200."), + "include_completed": mcp.Bool("Include tasks that are already done. Defaults to false."), + }), + }, s.mcpSearchTasks) + + m.Register(mcp.Tool{ + Name: "create_task", + Description: "Create a task. Prefer passing the whole thing as natural language in " + + "`text` — \"betal moms i morgen kl 10 p1 #Firma @regnskab\" — because the app's own " + + "parser handles dates, times, priorities, projects, labels and recurrence in Danish " + + "and English. Use the explicit fields only when the user was explicit.", + InputSchema: mcp.Schema(map[string]any{ + "text": mcp.Str("The task as a sentence, parsed for date, time, priority, #project, @label and recurrence."), + "content": mcp.Str("The task title, if not using `text`."), + "project_id": mcp.Str("Which project. Defaults to the Inbox."), + "due_date": mcp.Str("A date as YYYY-MM-DD."), + "priority": mcp.Int("1 (highest) to 4 (none)."), + "description": mcp.Str("Longer notes on the task."), + "labels": mcp.StrArray("Label names."), + }), + }, s.mcpCreateTask) + + m.Register(mcp.Tool{ + Name: "update_task", + Description: "Change an existing task: its title, description, priority, due date or " + + "project. Only the fields given are changed. To move a due date, pass due_date; " + + "to clear it, pass an empty string.", + InputSchema: mcp.Schema(map[string]any{ + "task_id": mcp.Str("The task to change."), + "content": mcp.Str("A new title."), + "description": mcp.Str("New notes."), + "priority": mcp.Int("1 (highest) to 4 (none)."), + "due_date": mcp.Str("A date as YYYY-MM-DD, or an empty string to remove the date."), + "project_id": mcp.Str("Move the task to another project."), + "labels": mcp.StrArray("Replace the task's labels."), + }, "task_id"), + }, s.mcpUpdateTask) + + m.Register(mcp.Tool{ + Name: "complete_task", + Description: "Mark a task done. A repeating task advances to its next occurrence " + + "instead of closing, and the result says so.", + InputSchema: mcp.Schema(map[string]any{ + "task_id": mcp.Str("The task to complete."), + }, "task_id"), + }, s.mcpCompleteTask) + + m.Register(mcp.Tool{ + Name: "add_comment", + Description: "Add a comment to a task. Everyone else with access to the project is notified.", + InputSchema: mcp.Schema(map[string]any{ + "task_id": mcp.Str("The task to comment on."), + "body": mcp.Str("What to say."), + }, "task_id", "body"), + }, s.mcpAddComment) + + return m +} + +// handleMCP is the Streamable HTTP transport: one POST carries one JSON-RPC +// message and the response comes back in the body. +// +// Authentication is the ordinary API-token path, so a connector reaches exactly +// what its owner reaches. That is why there is no separate scope model here — +// a token is a person, and a person's permissions are already decided. +func (s *Server) handleMCP(w http.ResponseWriter, r *http.Request) { + user := userFrom(r.Context()) + + body, err := io.ReadAll(io.LimitReader(r.Body, 1<<20)) + if err != nil { + writeError(w, http.StatusBadRequest, CodeBadRequest, "could not read the request") + return + } + + response := s.mcp.Handle(r.Context(), user.ID, body) + if response == nil { + // A notification. The spec says answer with nothing. + w.WriteHeader(http.StatusAccepted) + return + } + writeJSON(w, http.StatusOK, response) +} + +// --- tool implementations ------------------------------------------------------------ + +func (s *Server) mcpListProjects(ctx context.Context, userID string, args json.RawMessage) (any, error) { + var params struct { + IncludeArchived bool `json:"include_archived"` + } + _ = json.Unmarshal(args, ¶ms) + + projects, err := s.db.ListProjects(ctx, userID, params.IncludeArchived) + if err != nil { + return nil, err + } + + out := make([]map[string]any, 0, len(projects)) + for _, p := range projects { + out = append(out, map[string]any{ + "id": p.ID, "name": p.Name, "is_inbox": p.IsInbox, + "shared": p.MemberCount > 1, "role": string(p.Role), "view": p.ViewMode, + }) + } + return map[string]any{"projects": out}, nil +} + +func (s *Server) mcpSearchTasks(ctx context.Context, userID string, args json.RawMessage) (any, error) { + var params struct { + Query string `json:"query"` + Text string `json:"text"` + Limit int `json:"limit"` + IncludeCompleted bool `json:"include_completed"` + } + _ = json.Unmarshal(args, ¶ms) + + user, err := s.db.UserByID(ctx, userID) + if err != nil { + return nil, err + } + + filter := store.TaskFilter{ + Search: params.Text, + IncludeCompleted: params.IncludeCompleted, + Limit: clamp(params.Limit, 50, 200), + } + if params.Query != "" { + compiled, err := parseFilter(params.Query, user) + if err != nil { + return nil, mcp.ArgError("that filter could not be read: %v. "+ + "Valid examples: \"today\", \"overdue\", \"today & p1\", \"#Firma & @regnskab\", \"7 days\".", err) + } + filter.FilterSQL, filter.FilterArgs = compiled.SQL, compiled.Args + } + + tasks, err := s.db.ListTasks(ctx, userID, filter) + if err != nil { + return nil, err + } + + projects, _ := s.db.ListProjects(ctx, userID, true) + names := map[string]string{} + for _, p := range projects { + names[p.ID] = p.Name + } + + out := make([]map[string]any, 0, len(tasks)) + for _, t := range tasks { + entry := map[string]any{ + "id": t.ID, "content": t.Content, "priority": t.Priority, + "project": names[t.ProjectID], "project_id": t.ProjectID, + "completed": t.Completed(), + } + if t.DueDate != "" { + entry["due_date"] = t.DueDate + } + if t.Description != "" { + entry["description"] = t.Description + } + if len(t.Labels) > 0 { + entry["labels"] = t.Labels + } + if t.RecurrenceRule != "" { + entry["repeats"] = t.RecurrenceRule + } + out = append(out, entry) + } + return map[string]any{"tasks": out, "count": len(out)}, nil +} + +func (s *Server) mcpCreateTask(ctx context.Context, userID string, args json.RawMessage) (any, error) { + var params struct { + Text string `json:"text"` + Content string `json:"content"` + ProjectID string `json:"project_id"` + DueDate string `json:"due_date"` + Priority int `json:"priority"` + Description string `json:"description"` + Labels []string `json:"labels"` + } + _ = json.Unmarshal(args, ¶ms) + + user, err := s.db.UserByID(ctx, userID) + if err != nil { + return nil, err + } + + task := &store.Task{CreatedBy: userID, Priority: 4} + labels := params.Labels + + if text := strings.TrimSpace(params.Text); text != "" { + parsed := quickadd.Parse(text, time.Now().In(userLocation(user.Timezone)), user.Locale) + task.Content = parsed.Content + task.Priority = parsed.Priority + task.DueDate = parsed.DueDate + task.RecurrenceRule = parsed.Recurrence + if len(labels) == 0 { + labels = parsed.Labels + } + if parsed.Project != "" && params.ProjectID == "" { + if id, err := s.db.ProjectByName(ctx, userID, parsed.Project); err == nil { + task.ProjectID = id + } + } + } + + if params.Content != "" { + task.Content = params.Content + } + if strings.TrimSpace(task.Content) == "" { + return nil, mcp.ArgError("there is no task text — pass either `text` or `content`") + } + if params.Description != "" { + task.Description = params.Description + } + if params.Priority >= 1 && params.Priority <= 4 { + task.Priority = params.Priority + } + if params.DueDate != "" { + if _, err := time.Parse("2006-01-02", params.DueDate); err != nil { + return nil, mcp.ArgError("due_date must look like 2026-03-15, got %q", params.DueDate) + } + task.DueDate = params.DueDate + } + if params.ProjectID != "" { + task.ProjectID = params.ProjectID + } + if task.ProjectID == "" { + if task.ProjectID, err = s.db.InboxID(ctx, userID); err != nil { + return nil, err + } + } + + if _, err := store.RequireProjectRole(ctx, s.db, task.ProjectID, userID, store.RoleEditor); err != nil { + return nil, mcp.ArgError("no project with id %q that you can write to", task.ProjectID) + } + if err := s.db.CreateTask(ctx, task, labels); err != nil { + return nil, err + } + + s.hub.Publish(task.ProjectID, "task.created", toTaskJSON(*task)) + return map[string]any{ + "id": task.ID, "content": task.Content, "priority": task.Priority, + "due_date": task.DueDate, "project_id": task.ProjectID, + }, nil +} + +func (s *Server) mcpUpdateTask(ctx context.Context, userID string, args json.RawMessage) (any, error) { + var params struct { + TaskID string `json:"task_id"` + Content *string `json:"content"` + Description *string `json:"description"` + Priority *int `json:"priority"` + DueDate *string `json:"due_date"` + ProjectID *string `json:"project_id"` + Labels *[]string `json:"labels"` + } + _ = json.Unmarshal(args, ¶ms) + + if params.TaskID == "" { + return nil, mcp.ArgError("task_id is required — find one with search_tasks first") + } + role, err := store.TaskRole(ctx, s.db, params.TaskID, userID) + if err != nil || !role.CanEdit() { + return nil, mcp.ArgError("no task with id %q that you can change", params.TaskID) + } + + update := store.TaskUpdate{ + Content: params.Content, Description: params.Description, + Priority: params.Priority, SectionID: nil, + } + if params.Priority != nil && (*params.Priority < 1 || *params.Priority > 4) { + return nil, mcp.ArgError("priority must be 1, 2, 3 or 4") + } + if params.DueDate != nil { + update.SetDue = true + if *params.DueDate != "" { + if _, err := time.Parse("2006-01-02", *params.DueDate); err != nil { + return nil, mcp.ArgError("due_date must look like 2026-03-15, or be empty to clear it") + } + update.DueDate = *params.DueDate + } + } + if params.Labels != nil { + update.SetLabels, update.Labels = true, *params.Labels + } + if params.ProjectID != nil { + if _, err := store.RequireProjectRole(ctx, s.db, *params.ProjectID, userID, store.RoleEditor); err != nil { + return nil, mcp.ArgError("no project with id %q that you can write to", *params.ProjectID) + } + } + + if err := s.db.UpdateTask(ctx, params.TaskID, userID, update); err != nil { + return nil, err + } + task, err := s.db.GetTask(ctx, params.TaskID, userID) + if err != nil { + return nil, err + } + s.hub.Publish(task.ProjectID, "task.updated", toTaskJSON(*task)) + + return map[string]any{ + "id": task.ID, "content": task.Content, "priority": task.Priority, + "due_date": task.DueDate, "labels": task.Labels, + }, nil +} + +func (s *Server) mcpCompleteTask(ctx context.Context, userID string, args json.RawMessage) (any, error) { + var params struct { + TaskID string `json:"task_id"` + } + _ = json.Unmarshal(args, ¶ms) + + if params.TaskID == "" { + return nil, mcp.ArgError("task_id is required — find one with search_tasks first") + } + role, err := store.TaskRole(ctx, s.db, params.TaskID, userID) + if err != nil || !role.CanEdit() { + return nil, mcp.ArgError("no task with id %q that you can complete", params.TaskID) + } + + result, err := s.db.CompleteTask(ctx, params.TaskID, userID) + if err != nil { + return nil, err + } + task, err := s.db.GetTask(ctx, params.TaskID, userID) + if err != nil { + return nil, err + } + s.hub.Publish(task.ProjectID, "task.completed", toTaskJSON(*task)) + + out := map[string]any{"id": task.ID, "content": task.Content, "completed": task.Completed()} + if result.Recurred { + out["recurred"] = true + out["next_due"] = result.NextDue + out["note"] = "This task repeats; it has moved to its next occurrence rather than closing." + } + return out, nil +} + +func (s *Server) mcpAddComment(ctx context.Context, userID string, args json.RawMessage) (any, error) { + var params struct { + TaskID string `json:"task_id"` + Body string `json:"body"` + } + _ = json.Unmarshal(args, ¶ms) + + if params.TaskID == "" || strings.TrimSpace(params.Body) == "" { + return nil, mcp.ArgError("both task_id and body are required") + } + role, err := store.TaskRole(ctx, s.db, params.TaskID, userID) + if err != nil || !role.CanEdit() { + return nil, mcp.ArgError("no task with id %q that you can comment on", params.TaskID) + } + + comment, err := s.db.CreateComment(ctx, params.TaskID, userID, strings.TrimSpace(params.Body)) + if err != nil { + return nil, err + } + return map[string]any{"id": comment.ID, "task_id": comment.TaskID, "body": comment.Body}, nil +} + +func clamp(value, fallback, max int) int { + if value <= 0 { + return fallback + } + if value > max { + return max + } + return value +} diff --git a/internal/httpapi/middleware.go b/internal/httpapi/middleware.go index 7d7d320..5668246 100644 --- a/internal/httpapi/middleware.go +++ b/internal/httpapi/middleware.go @@ -151,6 +151,12 @@ func bearerToken(r *http.Request) string { return "" } +// withUser puts a user on the context. Used by the CalDAV handlers, which +// authenticate with Basic rather than through the session middleware. +func withUser(ctx context.Context, user *store.User) context.Context { + return context.WithValue(ctx, ctxUser, user) +} + func userFrom(ctx context.Context) *store.User { u, _ := ctx.Value(ctxUser).(*store.User) return u diff --git a/internal/httpapi/server.go b/internal/httpapi/server.go index bf28016..a48e04a 100644 --- a/internal/httpapi/server.go +++ b/internal/httpapi/server.go @@ -18,6 +18,7 @@ import ( "github.com/kristianwind/verdande/internal/config" "github.com/kristianwind/verdande/internal/mail" + "github.com/kristianwind/verdande/internal/mcp" "github.com/kristianwind/verdande/internal/realtime" "github.com/kristianwind/verdande/internal/store" ) @@ -29,6 +30,7 @@ type Server struct { web fs.FS mail *mail.Sender hub *realtime.Hub + mcp *mcp.Server // Password guessing is the attack this application is actually exposed to, so // the endpoints that check a secret are limited separately and more tightly @@ -54,6 +56,7 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server resetLimiter: newLimiter(5, time.Hour), } s.csp = contentSecurityPolicy(scriptHashes(web)) + s.mcp = s.buildMCP() r := chi.NewRouter() r.Use(middleware.RequestID) @@ -71,6 +74,15 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server // client cannot log in, so the token in the path is the whole credential. r.Get("/ics/{token}", s.handleICSFeed) + // CalDAV, outside /api/v1 because clients do well-known-path discovery + // against the root and authenticate with Basic rather than a session. + r.Get("/.well-known/caldav", s.wellKnownCalDAV) + + // Inbound mail. Delivered by the mail server rather than by a browser, and + // authenticated by the token in the recipient address. + r.Post("/inbound/mail", s.handleInboundMail) + r.Route("/caldav", s.caldavRoutes) + r.Route("/api/v1", func(r chi.Router) { r.Use(s.requireCSRF) @@ -119,6 +131,10 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server r.Get("/ws", s.handleWebSocket) + // The MCP endpoint. Reachable with a personal API token, so a connector + // sees exactly what its owner sees. + r.Post("/mcp", s.handleMCP) + r.Get("/today", s.handleToday) r.Get("/filters/preview", s.handlePreviewFilter) @@ -155,6 +171,22 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server }) r.Post("/feed/rotate", s.handleRotateFeed) + r.Get("/mail-address", s.handleGetMailAddress) + r.Post("/mail-address/rotate", s.handleRotateMailAddress) + + r.Route("/ai", func(r chi.Router) { + r.Get("/settings", s.handleGetAISettings) + r.Put("/settings", s.handleSetAISettings) + r.Post("/summary", s.handleAISummary) + r.Post("/tasks/{taskID}/split", s.handleAISplit) + }) + + r.Route("/gmail", func(r chi.Router) { + r.Get("/", s.handleGetGmail) + r.Put("/", s.handleSetGmail) + r.Delete("/", s.handleDisconnectGmail) + }) + r.Route("/import", func(r chi.Router) { r.Post("/todoist", s.handleImportTodoist) r.Post("/csv", s.handleImportCSV) diff --git a/internal/ics/ics.go b/internal/ics/ics.go index 9558170..dd8a218 100644 --- a/internal/ics/ics.go +++ b/internal/ics/ics.go @@ -13,6 +13,7 @@ package ics import ( "fmt" + "strconv" "strings" "time" ) @@ -221,3 +222,148 @@ func Filename(name string) string { } return fmt.Sprintf("%s.ics", safe) } + +// --- reading ---------------------------------------------------------------------- + +// Parsed is what a client's VTODO says, reduced to the fields verdande stores. +type Parsed struct { + UID string + Summary string + Description string + DueDate string + DueDatetime *time.Time + Priority int + Recurrence string + Completed bool +} + +// ParseVTODO reads a calendar object sent by a CalDAV client. +// +// Deliberately forgiving: this is the input side of a protocol spoken by Apple +// Reminders, Thunderbird and a long tail of phone apps, each with its own habits +// about folding, parameters and which properties it bothers to send. Anything not +// understood is ignored rather than refused, because refusing a PUT makes a client +// show a sync error and stop — losing the edit the person just made. +func ParseVTODO(body string) (Parsed, error) { + var p Parsed + inTodo := false + + for _, line := range unfold(body) { + name, params, value := splitLine(line) + + switch strings.ToUpper(name) { + case "BEGIN": + if strings.EqualFold(value, "VTODO") { + inTodo = true + } + case "END": + if strings.EqualFold(value, "VTODO") { + inTodo = false + } + } + if !inTodo { + continue + } + + switch strings.ToUpper(name) { + case "UID": + p.UID = value + case "SUMMARY": + p.Summary = unescape(value) + case "DESCRIPTION": + p.Description = unescape(value) + case "RRULE": + p.Recurrence = value + case "STATUS": + p.Completed = strings.EqualFold(value, "COMPLETED") + case "PERCENT-COMPLETE": + if value == "100" { + p.Completed = true + } + case "COMPLETED": + p.Completed = true + case "PRIORITY": + // RFC 5545 runs 1..9 with 0 meaning undefined; verdande runs 1..4 with + // 4 meaning none. The bands here are the inverse of what the writer does. + switch n, _ := strconv.Atoi(value); { + case n == 0: + p.Priority = 4 + case n <= 2: + p.Priority = 1 + case n <= 5: + p.Priority = 2 + case n <= 7: + p.Priority = 3 + default: + p.Priority = 4 + } + case "DUE": + if strings.Contains(strings.ToUpper(params), "VALUE=DATE") || len(value) == 8 { + if t, err := time.Parse("20060102", value); err == nil { + p.DueDate = t.Format("2006-01-02") + } + continue + } + for _, layout := range []string{"20060102T150405Z", "20060102T150405"} { + if t, err := time.Parse(layout, value); err == nil { + utc := t.UTC() + p.DueDate = utc.Format("2006-01-02") + p.DueDatetime = &utc + break + } + } + } + } + + if p.Priority == 0 { + p.Priority = 4 + } + if p.Summary == "" && p.UID == "" { + return p, fmt.Errorf("ics: no VTODO found") + } + return p, nil +} + +// unfold rejoins continuation lines, which the spec folds at 75 octets by beginning +// the next line with a space or tab. +func unfold(body string) []string { + raw := strings.Split(strings.ReplaceAll(body, "\r\n", "\n"), "\n") + + var out []string + for _, line := range raw { + if len(line) > 0 && (line[0] == ' ' || line[0] == '\t') && len(out) > 0 { + out[len(out)-1] += line[1:] + continue + } + out = append(out, line) + } + return out +} + +// splitLine separates NAME;PARAMS:VALUE. The parameters can themselves contain a +// colon inside quotes, so the split is on the first colon outside quotes. +func splitLine(line string) (name, params, value string) { + inQuotes := false + for i := 0; i < len(line); i++ { + switch line[i] { + case '"': + inQuotes = !inQuotes + case ':': + if inQuotes { + continue + } + left := line[:i] + value = line[i+1:] + if semi := strings.Index(left, ";"); semi >= 0 { + return left[:semi], left[semi+1:], value + } + return left, "", value + } + } + return line, "", "" +} + +func unescape(s string) string { + r := strings.NewReplacer(`\n`, "\n", `\N`, "\n", `\,`, ",", `\;`, ";", `\\`, `\`) + return r.Replace(s) +} diff --git a/internal/mcp/mcp.go b/internal/mcp/mcp.go new file mode 100644 index 0000000..fbd8370 --- /dev/null +++ b/internal/mcp/mcp.go @@ -0,0 +1,229 @@ +// Package mcp implements a Model Context Protocol server over Streamable HTTP, so +// verdande can be added to Claude as a connector. +// +// MCP is JSON-RPC 2.0 with a fixed handshake and a fixed set of methods. It is +// implemented directly here rather than through an SDK: the surface verdande needs +// is initialize, tools/list and tools/call, and the whole thing is smaller than the +// dependency would be. +// +// Authentication is a personal API token in the Authorization header, checked by +// the same middleware as the rest of the API — so a tool call can reach exactly +// what its owner can reach, and nothing else. There is no separate service identity +// to reason about, which is the point. +package mcp + +import ( + "context" + "encoding/json" + "fmt" +) + +// The protocol version this server speaks. Claude sends its own in initialize; a +// mismatch is not fatal, and answering with what we actually implement is what the +// spec asks for. +const ProtocolVersion = "2024-11-05" + +// --- JSON-RPC --------------------------------------------------------------------- + +type Request struct { + JSONRPC string `json:"jsonrpc"` + ID json.RawMessage `json:"id,omitempty"` + Method string `json:"method"` + Params json.RawMessage `json:"params,omitempty"` +} + +type Response struct { + JSONRPC string `json:"jsonrpc"` + ID json.RawMessage `json:"id,omitempty"` + Result any `json:"result,omitempty"` + Error *RPCError `json:"error,omitempty"` +} + +type RPCError struct { + Code int `json:"code"` + Message string `json:"message"` + Data any `json:"data,omitempty"` +} + +// The JSON-RPC error codes, as the spec numbers them. +const ( + CodeParseError = -32700 + CodeInvalidRequest = -32600 + CodeMethodNotFound = -32601 + CodeInvalidParams = -32602 + CodeInternalError = -32603 +) + +func result(id json.RawMessage, value any) *Response { + return &Response{JSONRPC: "2.0", ID: id, Result: value} +} + +func failure(id json.RawMessage, code int, message string) *Response { + return &Response{JSONRPC: "2.0", ID: id, Error: &RPCError{Code: code, Message: message}} +} + +// --- tools ------------------------------------------------------------------------- + +// Tool is one thing Claude can do. The schema is what it reads to decide how to +// call it, so the descriptions are written for a model rather than for a developer: +// they say when to use the tool, not merely what it does. +type Tool struct { + Name string `json:"name"` + Description string `json:"description"` + InputSchema map[string]any `json:"inputSchema"` +} + +// Handler executes a tool call for a particular user. Returning an error produces a +// tool error rather than a protocol error — a model can read and recover from the +// first, and can do nothing at all with the second. +type Handler func(ctx context.Context, userID string, args json.RawMessage) (any, error) + +type Server struct { + tools []Tool + handlers map[string]Handler + // Name and Version identify this instance in the connector list. + Name string + Version string +} + +func NewServer(name, version string) *Server { + return &Server{Name: name, Version: version, handlers: map[string]Handler{}} +} + +func (s *Server) Register(tool Tool, handler Handler) { + s.tools = append(s.tools, tool) + s.handlers[tool.Name] = handler +} + +// Handle dispatches one JSON-RPC message. +// +// A nil response means the message was a notification — it had no id, so the spec +// says to answer with nothing at all. Answering anyway is a protocol violation that +// some clients tolerate and others disconnect over. +func (s *Server) Handle(ctx context.Context, userID string, raw []byte) *Response { + var req Request + if err := json.Unmarshal(raw, &req); err != nil { + return failure(nil, CodeParseError, "invalid JSON") + } + if req.JSONRPC != "2.0" { + return failure(req.ID, CodeInvalidRequest, "jsonrpc must be \"2.0\"") + } + + isNotification := len(req.ID) == 0 + + switch req.Method { + case "initialize": + return result(req.ID, map[string]any{ + "protocolVersion": ProtocolVersion, + "capabilities": map[string]any{ + // Only tools. verdande has no prompts or resources to offer that + // the tools do not already cover, and declaring capabilities that + // answer with nothing wastes a round trip on every connection. + "tools": map[string]any{}, + }, + "serverInfo": map[string]any{"name": s.Name, "version": s.Version}, + }) + + case "notifications/initialized", "initialized": + return nil + + case "ping": + return result(req.ID, map[string]any{}) + + case "tools/list": + return result(req.ID, map[string]any{"tools": s.tools}) + + case "tools/call": + return s.callTool(ctx, userID, req) + + default: + if isNotification { + return nil + } + return failure(req.ID, CodeMethodNotFound, "unknown method: "+req.Method) + } +} + +type toolCallParams struct { + Name string `json:"name"` + Arguments json.RawMessage `json:"arguments"` +} + +func (s *Server) callTool(ctx context.Context, userID string, req Request) *Response { + var params toolCallParams + if err := json.Unmarshal(req.Params, ¶ms); err != nil { + return failure(req.ID, CodeInvalidParams, "could not read the call parameters") + } + + handler, ok := s.handlers[params.Name] + if !ok { + return failure(req.ID, CodeMethodNotFound, "unknown tool: "+params.Name) + } + + value, err := handler(ctx, userID, params.Arguments) + if err != nil { + // A failed tool call is a *result* with isError set, not a JSON-RPC error. + // The distinction matters: this way the model is told what went wrong and + // can try something else, rather than the conversation failing. + return result(req.ID, map[string]any{ + "content": []map[string]any{{"type": "text", "text": err.Error()}}, + "isError": true, + }) + } + + // Tool results are text content. JSON is rendered indented because a model + // reads it as text, and one line of dense JSON is markedly harder for it to + // pick a field out of than a formatted block. + encoded, err := json.MarshalIndent(value, "", " ") + if err != nil { + return failure(req.ID, CodeInternalError, "could not encode the result") + } + return result(req.ID, map[string]any{ + "content": []map[string]any{{"type": "text", "text": string(encoded)}}, + }) +} + +// --- schema helpers ------------------------------------------------------------------- + +// Schema builds a JSON Schema object for a tool's arguments. +func Schema(properties map[string]any, required ...string) map[string]any { + if required == nil { + required = []string{} + } + return map[string]any{ + "type": "object", + "properties": properties, + "required": required, + } +} + +func Str(description string) map[string]any { + return map[string]any{"type": "string", "description": description} +} + +func Int(description string) map[string]any { + return map[string]any{"type": "integer", "description": description} +} + +func Bool(description string) map[string]any { + return map[string]any{"type": "boolean", "description": description} +} + +func Enum(description string, values ...any) map[string]any { + return map[string]any{"type": "string", "description": description, "enum": values} +} + +func StrArray(description string) map[string]any { + return map[string]any{ + "type": "array", + "description": description, + "items": map[string]any{"type": "string"}, + } +} + +// ArgError is what a handler returns when the model called it wrongly. Phrased as +// an instruction rather than a complaint, because the reader is a model deciding +// what to do next. +func ArgError(format string, args ...any) error { + return fmt.Errorf(format, args...) +} diff --git a/internal/store/notifications.go b/internal/store/notifications.go index 6437067..840515b 100644 --- a/internal/store/notifications.go +++ b/internal/store/notifications.go @@ -3,7 +3,10 @@ package store import ( "context" "database/sql" + "encoding/json" "time" + + "github.com/kristianwind/verdande/internal/auth" ) type Notification struct { @@ -230,3 +233,78 @@ func (db *DB) InstanceKeys(ctx context.Context, generate func() (public, private } return public, private, nil } + +// --- mail-to-task tokens ------------------------------------------------------------- + +func (db *DB) EnsureMailToken(ctx context.Context, userID string) (string, error) { + var token sql.NullString + err := db.QueryRowContext(ctx, `SELECT mail_token FROM users WHERE id = ?`, userID).Scan(&token) + if err != nil { + return "", err + } + if token.Valid && token.String != "" { + return token.String, nil + } + + fresh, err := auth.NewToken() + if err != nil { + return "", err + } + if err := db.SetMailToken(ctx, userID, fresh); err != nil { + return "", err + } + return fresh, nil +} + +func (db *DB) SetMailToken(ctx context.Context, userID, token string) error { + _, err := db.ExecContext(ctx, + `UPDATE users SET mail_token = ?, updated_at = ? WHERE id = ?`, + token, time.Now().Unix(), userID) + return err +} + +func (db *DB) UserByMailToken(ctx context.Context, token string) (*User, error) { + if token == "" { + return nil, ErrNotFound + } + return db.scanUser(ctx, `SELECT `+userColumns+` FROM users WHERE mail_token = ?`, token) +} + +// --- per-user settings ------------------------------------------------------------------ + +// UserSettings reads one scope of a person's integration settings. A missing row is +// an empty map, not an error: nobody has settings until they set some. +func (db *DB) UserSettings(ctx context.Context, userID, scope string) (map[string]any, error) { + var raw string + err := db.QueryRowContext(ctx, + `SELECT values_json FROM user_settings WHERE user_id = ? AND scope = ?`, + userID, scope).Scan(&raw) + if err == sql.ErrNoRows { + return map[string]any{}, nil + } + if err != nil { + return nil, err + } + + values := map[string]any{} + if err := json.Unmarshal([]byte(raw), &values); err != nil { + return map[string]any{}, nil + } + return values, nil +} + +func (db *DB) SetUserSettings(ctx context.Context, userID, scope string, values map[string]any) error { + raw, err := json.Marshal(values) + if err != nil { + return err + } + _, err = db.ExecContext(ctx, ` + INSERT INTO user_settings (user_id, scope, values_json, updated_at) + VALUES (?, ?, ?, ?) + ON CONFLICT (user_id) DO UPDATE SET + scope = excluded.scope, + values_json = excluded.values_json, + updated_at = excluded.updated_at`, + userID, scope, string(raw), time.Now().Unix()) + return err +}