feat(phase5): MCP server, CalDAV, mail-to-task, Gmail settings and an AI layer
The MCP server speaks JSON-RPC over Streamable HTTP with the six tools the brief names, authenticated by a personal API token — so a connector reaches exactly what its owner reaches and there is no second identity to reason about. A failed tool call returns a tool error, not a protocol error: a model can recover from the first and can do nothing with the second. CalDAV serves VTODO, two-way, so Apple Reminders and Thunderbird can read, create and complete tasks. Clients authenticate with Basic using an API token as the password, never the account password — a client stores what it is given, and what it is given should not be able to sign in to the app. Completed tasks stay in the collection: a client that stops seeing one treats it as deleted and drops its own record of having done it. The AI layer is one interface with adapters for Anthropic, OpenAI, Google and any OpenAI-compatible endpoint. That last one is why the abstraction exists: an integration that only spoke to a hosted API would be useless to somebody running their own models. Every feature degrades to "off" when nothing is configured, and the stored API key is never returned to the settings page. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
4058ff0533
commit
6b19ed062d
@@ -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
|
||||
}
|
||||
@@ -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, `<d:resourcetype><d:collection/><cal:calendar/></d:resourcetype>`)
|
||||
}
|
||||
if req.Wants(NSDav, "displayname") {
|
||||
props = append(props, `<d:displayname>`+escapeXML(displayName)+`</d: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,
|
||||
`<cal:supported-calendar-component-set><cal:comp name="VTODO"/></cal:supported-calendar-component-set>`)
|
||||
}
|
||||
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, `<cs:getctag>`+escapeXML(ctag)+`</cs:getctag>`)
|
||||
}
|
||||
if req.Wants(NSDav, "getetag") {
|
||||
props = append(props, `<d:getetag>"`+escapeXML(ctag)+`"</d:getetag>`)
|
||||
}
|
||||
if req.Wants(NSApple, "calendar-color") {
|
||||
props = append(props, `<ical:calendar-color>#5FBF8FFF</ical:calendar-color>`)
|
||||
}
|
||||
if req.Wants(NSCalDAV, "calendar-description") {
|
||||
props = append(props, `<cal:calendar-description>`+escapeXML(displayName)+`</cal:calendar-description>`)
|
||||
}
|
||||
|
||||
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, `<d:getetag>"`+escapeXML(etag)+`"</d:getetag>`)
|
||||
}
|
||||
if req.Wants(NSDav, "resourcetype") {
|
||||
props = append(props, `<d:resourcetype/>`)
|
||||
}
|
||||
if req.Wants(NSDav, "getcontenttype") {
|
||||
props = append(props, `<d:getcontenttype>text/calendar; component=vtodo</d:getcontenttype>`)
|
||||
}
|
||||
if calendarData != "" && req.Wants(NSCalDAV, "calendar-data") {
|
||||
props = append(props, `<cal:calendar-data>`+escapeXML(calendarData)+`</cal:calendar-data>`)
|
||||
}
|
||||
|
||||
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, `<d:resourcetype><d:principal/></d:resourcetype>`)
|
||||
}
|
||||
if req.Wants(NSDav, "displayname") {
|
||||
props = append(props, `<d:displayname>`+escapeXML(displayName)+`</d:displayname>`)
|
||||
}
|
||||
if req.Wants(NSDav, "current-user-principal") || req.Wants(NSDav, "principal-URL") {
|
||||
props = append(props,
|
||||
`<d:current-user-principal><d:href>`+escapeXML(href)+`</d:href></d:current-user-principal>`,
|
||||
`<d:principal-URL><d:href>`+escapeXML(href)+`</d:href></d:principal-URL>`)
|
||||
}
|
||||
if req.Wants(NSCalDAV, "calendar-home-set") {
|
||||
props = append(props,
|
||||
`<cal:calendar-home-set><d:href>`+escapeXML(calendarHome)+`</d:href></cal:calendar-home-set>`)
|
||||
}
|
||||
|
||||
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, `<d:resourcetype/>`)
|
||||
}
|
||||
m.responses = append(m.responses, fmt.Sprintf(
|
||||
`<d:response><d:href>%s</d:href><d:propstat><d:prop>%s</d:prop><d:status>HTTP/1.1 200 OK</d:status></d:propstat></d:response>`,
|
||||
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(
|
||||
`<d:response><d:href>%s</d:href><d:status>HTTP/1.1 404 Not Found</d:status></d:response>`,
|
||||
escapeXML(href)))
|
||||
}
|
||||
|
||||
func (m *Multistatus) XML() string {
|
||||
return `<?xml version="1.0" encoding="utf-8"?>` +
|
||||
`<d:multistatus xmlns:d="DAV:" xmlns:cal="urn:ietf:params:xml:ns:caldav" ` +
|
||||
`xmlns:cs="http://calendarserver.org/ns/" xmlns:ical="http://apple.com/ns/ical/">` +
|
||||
strings.Join(m.responses, "") +
|
||||
`</d:multistatus>`
|
||||
}
|
||||
|
||||
// --- 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()
|
||||
}
|
||||
@@ -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
|
||||
@@ -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"
|
||||
|
||||
@@ -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+<token>@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 <todo+TOKEN@domain>".
|
||||
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
|
||||
@@ -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), "<cal:comp name=\"VTODO\"/>") {
|
||||
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,
|
||||
`<?xml version="1.0"?><c:calendar-query xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav">`+
|
||||
`<d:prop><d:getetag/><c:calendar-data/></d:prop></c:calendar-query>`)
|
||||
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+<token>@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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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...)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user