From bf29d4ca2e8b7d6addc7278849579dd006c03add Mon Sep 17 00:00:00 2001 From: dj-oyu <68707227+dj-oyu@users.noreply.github.com> Date: Sun, 22 Feb 2026 05:26:26 +0900 Subject: [PATCH] feat: add Telegram Mini App dashboard - Single-file HTML Mini App with 4 tabs: Plan, Skills, Session, Config - Plan tab shows phase/step list parsed from MEMORY.md with tap-to-complete - Skills tab lists available skills with send bar for command construction - Tailscale auto-detection for HTTPS hosting with TLS cert provisioning - HMAC-SHA256 validation of Telegram initData for API authentication - WebAppData handler in Telegram channel for command relay - Menu Button registration for "Dashboard" in Telegram - Health server extended with Mux() accessor and StartTLS support - GetPlanPhases() as single source of truth for plan parsing Co-Authored-By: Claude Opus 4.6 --- cmd/picoclaw/cmd_gateway.go | 98 +++++- config/config.example.json | 1 + pkg/agent/context.go | 5 + pkg/agent/loop.go | 49 +++ pkg/agent/memory.go | 133 +++++-- pkg/channels/telegram.go | 86 +++++ pkg/config/config.go | 9 +- pkg/health/server.go | 24 ++ pkg/miniapp/miniapp.go | 160 +++++++++ pkg/miniapp/miniapp_test.go | 99 ++++++ pkg/miniapp/static/index.html | 636 ++++++++++++++++++++++++++++++++++ pkg/tailscale/detect.go | 70 ++++ pkg/tailscale/detect_test.go | 59 ++++ 13 files changed, 1387 insertions(+), 42 deletions(-) create mode 100644 pkg/miniapp/miniapp.go create mode 100644 pkg/miniapp/miniapp_test.go create mode 100644 pkg/miniapp/static/index.html create mode 100644 pkg/tailscale/detect.go create mode 100644 pkg/tailscale/detect_test.go diff --git a/cmd/picoclaw/cmd_gateway.go b/cmd/picoclaw/cmd_gateway.go index 9a3b6aa19..a96e86bbe 100644 --- a/cmd/picoclaw/cmd_gateway.go +++ b/cmd/picoclaw/cmd_gateway.go @@ -21,8 +21,12 @@ import ( "github.com/sipeed/picoclaw/pkg/health" "github.com/sipeed/picoclaw/pkg/heartbeat" "github.com/sipeed/picoclaw/pkg/logger" + "github.com/sipeed/picoclaw/pkg/miniapp" "github.com/sipeed/picoclaw/pkg/providers" + "github.com/sipeed/picoclaw/pkg/skills" "github.com/sipeed/picoclaw/pkg/state" + "github.com/sipeed/picoclaw/pkg/stats" + "github.com/sipeed/picoclaw/pkg/tailscale" "github.com/sipeed/picoclaw/pkg/tools" "github.com/sipeed/picoclaw/pkg/voice" ) @@ -187,12 +191,55 @@ func gatewayCmd() { } healthServer := health.NewServer(cfg.Gateway.Host, cfg.Gateway.Port) + + // Mini App setup: register routes and determine TLS mode + useTLS := false + var tlsCert, tlsKey string + if cfg.Channels.Telegram.Enabled { + webAppURL := cfg.Channels.Telegram.WebAppURL + if webAppURL == "" { + // Auto-detect Tailscale hostname and fetch TLS cert + hostname, tsErr := tailscale.DetectHostname() + if tsErr != nil { + logger.InfoCF("miniapp", "Tailscale not available, Mini App disabled", map[string]any{"error": tsErr.Error()}) + } else { + certDir := filepath.Join(cfg.WorkspacePath(), "state", "certs") + certFile, keyFile, certErr := tailscale.FetchCert(hostname, certDir) + if certErr != nil { + logger.ErrorCF("miniapp", "Failed to fetch TLS cert", map[string]any{"error": certErr.Error()}) + } else { + webAppURL = fmt.Sprintf("https://%s:%d/miniapp", hostname, cfg.Gateway.Port) + cfg.Channels.Telegram.WebAppURL = webAppURL + tlsCert, tlsKey = certFile, keyFile + useTLS = true + } + } + } + + if webAppURL != "" { + provider := &agentLoopDataProvider{loop: agentLoop} + handler := miniapp.NewHandler(provider, cfg.Channels.Telegram.Token) + handler.RegisterRoutes(healthServer.Mux()) + fmt.Printf("✓ Mini App registered at %s\n", webAppURL) + } + } + go func() { - if err := healthServer.Start(); err != nil && err != http.ErrServerClosed { - logger.ErrorCF("health", "Health server error", map[string]any{"error": err.Error()}) + var serverErr error + if useTLS { + serverErr = healthServer.StartTLS(tlsCert, tlsKey) + } else { + serverErr = healthServer.Start() + } + if serverErr != nil && serverErr != http.ErrServerClosed { + logger.ErrorCF("health", "Health server error", map[string]any{"error": serverErr.Error()}) } }() - fmt.Printf("✓ Health endpoints available at http://%s:%d/health and /ready\n", cfg.Gateway.Host, cfg.Gateway.Port) + if useTLS { + fmt.Printf("✓ Health endpoints available at https://%s:%d/health and /ready (TLS)\n", cfg.Gateway.Host, cfg.Gateway.Port) + } else { + fmt.Printf("✓ Health endpoints available at http://%s:%d/health and /ready\n", cfg.Gateway.Host, cfg.Gateway.Port) + } go agentLoop.Run(ctx) @@ -236,3 +283,48 @@ func setupCronTool( return cronService } + +// agentLoopDataProvider adapts AgentLoop to the miniapp.DataProvider interface. +type agentLoopDataProvider struct { + loop *agent.AgentLoop +} + +func (p *agentLoopDataProvider) ListSkills() []skills.SkillInfo { + return p.loop.ListSkills() +} + +func (p *agentLoopDataProvider) GetPlanInfo() miniapp.PlanInfo { + hasPlan, status, currentPhase, totalPhases, display := p.loop.GetPlanInfo() + + // Convert agent.PlanPhase → miniapp.PlanPhase + agentPhases := p.loop.GetPlanPhases() + phases := make([]miniapp.PlanPhase, 0, len(agentPhases)) + for _, ap := range agentPhases { + steps := make([]miniapp.PlanStep, 0, len(ap.Steps)) + for _, as := range ap.Steps { + steps = append(steps, miniapp.PlanStep{ + Index: as.Index, + Description: as.Description, + Done: as.Done, + }) + } + phases = append(phases, miniapp.PlanPhase{ + Number: ap.Number, + Title: ap.Title, + Steps: steps, + }) + } + + return miniapp.PlanInfo{ + HasPlan: hasPlan, + Status: status, + CurrentPhase: currentPhase, + TotalPhases: totalPhases, + Display: display, + Phases: phases, + } +} + +func (p *agentLoopDataProvider) GetSessionStats() *stats.Stats { + return p.loop.GetSessionStats() +} diff --git a/config/config.example.json b/config/config.example.json index df88171ac..8db9dd3e9 100644 --- a/config/config.example.json +++ b/config/config.example.json @@ -53,6 +53,7 @@ "enabled": false, "token": "YOUR_TELEGRAM_BOT_TOKEN", "proxy": "", + "web_app_url": "", "allow_from": [ "YOUR_USER_ID" ] diff --git a/pkg/agent/context.go b/pkg/agent/context.go index 0c20b70e0..258134766 100644 --- a/pkg/agent/context.go +++ b/pkg/agent/context.go @@ -347,6 +347,11 @@ func (cb *ContextBuilder) ListSkills() []skills.SkillInfo { return cb.skillsLoader.ListSkills() } +// Memory returns the underlying MemoryStore for direct plan queries. +func (cb *ContextBuilder) Memory() *MemoryStore { + return cb.memory +} + // ---------- Plan passthrough methods ---------- // ReadMemory reads the long-term memory (MEMORY.md). diff --git a/pkg/agent/loop.go b/pkg/agent/loop.go index 7f42a62fd..740bfe5f8 100644 --- a/pkg/agent/loop.go +++ b/pkg/agent/loop.go @@ -1843,6 +1843,55 @@ func (al *AgentLoop) GetStartupInfo() map[string]any { return info } +// ListSkills returns all available skills from the default agent. +func (al *AgentLoop) ListSkills() []skills.SkillInfo { + agent := al.registry.GetDefaultAgent() + if agent == nil { + return nil + } + return agent.ContextBuilder.ListSkills() +} + +// GetPlanInfo returns plan state from the default agent's memory store. +func (al *AgentLoop) GetPlanInfo() (hasPlan bool, status string, currentPhase, totalPhases int, display string) { + agent := al.registry.GetDefaultAgent() + if agent == nil { + return false, "", 0, 0, "No agent available." + } + mem := agent.ContextBuilder.Memory() + if mem == nil { + return false, "", 0, 0, "No memory store." + } + hasPlan = mem.HasActivePlan() + status = mem.GetPlanStatus() + currentPhase = mem.GetCurrentPhase() + totalPhases = mem.GetTotalPhases() + display = mem.FormatPlanDisplay() + return +} + +// GetPlanPhases returns structured phase/step data from the default agent's plan. +func (al *AgentLoop) GetPlanPhases() []PlanPhase { + agent := al.registry.GetDefaultAgent() + if agent == nil { + return nil + } + mem := agent.ContextBuilder.Memory() + if mem == nil { + return nil + } + return mem.GetPlanPhases() +} + +// GetSessionStats returns the current session statistics snapshot, or nil if stats tracking is disabled. +func (al *AgentLoop) GetSessionStats() *stats.Stats { + if al.stats == nil { + return nil + } + s := al.stats.GetStats() + return &s +} + // formatMessagesForLog formats messages for logging func formatMessagesForLog(messages []providers.Message) string { if len(messages) == 0 { diff --git a/pkg/agent/memory.go b/pkg/agent/memory.go index a76be6903..92c097354 100644 --- a/pkg/agent/memory.go +++ b/pkg/agent/memory.go @@ -188,34 +188,43 @@ func (ms *MemoryStore) GetTotalPhases() int { // IsPlanComplete returns true if all steps in all phases are [x]. func (ms *MemoryStore) IsPlanComplete() bool { - content := ms.ReadLongTerm() - if !reActivePlan.MatchString(content) { + phases := ms.GetPlanPhases() + if len(phases) == 0 { return false } - // Must have at least one step - if !reStepDone.MatchString(content) && !reStepTodo.MatchString(content) { - return false + hasSteps := false + for _, p := range phases { + for _, s := range p.Steps { + hasSteps = true + if !s.Done { + return false + } + } } - // No unchecked steps - return !reStepTodo.MatchString(content) + return hasSteps } // IsCurrentPhaseComplete returns true if all steps in the current phase are [x]. func (ms *MemoryStore) IsCurrentPhaseComplete() bool { - content := ms.ReadLongTerm() - phase := ms.GetCurrentPhase() - if phase == 0 { + current := ms.GetCurrentPhase() + if current == 0 { return false } - phaseContent := ms.extractPhaseContent(content, phase) - if phaseContent == "" { - return false + phases := ms.GetPlanPhases() + for _, p := range phases { + if p.Number == current { + if len(p.Steps) == 0 { + return false + } + for _, s := range p.Steps { + if !s.Done { + return false + } + } + return true + } } - // Must have at least one step - if !reStepDone.MatchString(phaseContent) && !reStepTodo.MatchString(phaseContent) { - return false - } - return !reStepTodo.MatchString(phaseContent) + return false } // extractPhaseContent returns the content of a specific phase section. @@ -242,6 +251,65 @@ func (ms *MemoryStore) extractPhaseContent(content string, phase int) string { return strings.Join(result, "\n") } +// PlanPhase represents a phase with its steps, for structured API output. +type PlanPhase struct { + Number int `json:"number"` + Title string `json:"title"` + Steps []PlanStep `json:"steps"` +} + +// PlanStep represents a single step within a phase. +type PlanStep struct { + Index int `json:"index"` // 1-based within the phase + Description string `json:"description"` + Done bool `json:"done"` +} + +// GetPlanPhases parses MEMORY.md and returns all phases with their steps. +func (ms *MemoryStore) GetPlanPhases() []PlanPhase { + content := ms.ReadLongTerm() + if !reActivePlan.MatchString(content) { + return nil + } + + totalPhases := ms.GetTotalPhases() + phases := make([]PlanPhase, 0, totalPhases) + + for p := 1; p <= totalPhases; p++ { + title := ms.getPhaseTitle(content, p) + phaseContent := ms.extractPhaseContent(content, p) + + var steps []PlanStep + stepIdx := 0 + for _, line := range strings.Split(phaseContent, "\n") { + line = strings.TrimSpace(line) + if strings.HasPrefix(line, "- [x] ") { + stepIdx++ + steps = append(steps, PlanStep{ + Index: stepIdx, + Description: line[6:], + Done: true, + }) + } else if strings.HasPrefix(line, "- [ ] ") { + stepIdx++ + steps = append(steps, PlanStep{ + Index: stepIdx, + Description: line[6:], + Done: false, + }) + } + } + + phases = append(phases, PlanPhase{ + Number: p, + Title: title, + Steps: steps, + }) + } + + return phases +} + // ---------- Plan mutation methods ---------- // SetStatus sets the plan status (interviewing or executing). @@ -514,37 +582,32 @@ func (ms *MemoryStore) FormatPlanDisplay() string { } status := ms.GetPlanStatus() currentPhase := ms.GetCurrentPhase() - totalPhases := ms.GetTotalPhases() + phases := ms.GetPlanPhases() var sb strings.Builder sb.WriteString(fmt.Sprintf("Plan: %s\n", taskLine)) - sb.WriteString(fmt.Sprintf("Status: %s | Phase %d/%d\n\n", status, currentPhase, totalPhases)) - - for p := 1; p <= totalPhases; p++ { - title := ms.getPhaseTitle(content, p) - phaseContent := ms.extractPhaseContent(content, p) + sb.WriteString(fmt.Sprintf("Status: %s | Phase %d/%d\n\n", status, currentPhase, len(phases))) + for _, p := range phases { // Determine phase emoji var emoji string - if p < currentPhase { + if p.Number < currentPhase { emoji = "\u2705" // checkmark - } else if p == currentPhase { + } else if p.Number == currentPhase { emoji = "\u25B6\uFE0F" // play button } else { emoji = "\u23F3" // hourglass } - sb.WriteString(fmt.Sprintf("%s Phase %d: %s\n", emoji, p, title)) + sb.WriteString(fmt.Sprintf("%s Phase %d: %s\n", emoji, p.Number, p.Title)) // Show steps for current and completed phases - if p <= currentPhase { - lines := strings.Split(phaseContent, "\n") - for _, line := range lines { - line = strings.TrimSpace(line) - if strings.HasPrefix(line, "- [x] ") { - sb.WriteString(" \u2611 " + line[6:] + "\n") - } else if strings.HasPrefix(line, "- [ ] ") { - sb.WriteString(" \u2610 " + line[6:] + "\n") + if p.Number <= currentPhase { + for _, s := range p.Steps { + if s.Done { + sb.WriteString(" \u2611 " + s.Description + "\n") + } else { + sb.WriteString(" \u2610 " + s.Description + "\n") } } } diff --git a/pkg/channels/telegram.go b/pkg/channels/telegram.go index c66d6598b..006daf11d 100644 --- a/pkg/channels/telegram.go +++ b/pkg/channels/telegram.go @@ -142,6 +142,13 @@ func (c *TelegramChannel) Start(ctx context.Context) error { return c.handleQuickCommand(ctx, message) }, th.CommandEqual("skills")) + // WebAppData handler: intercept messages from Mini App before AnyMessage + bh.HandleMessage(func(ctx *th.Context, message telego.Message) error { + return c.handleWebAppData(ctx, message) + }, func(_ context.Context, update telego.Update) bool { + return update.Message != nil && update.Message.WebAppData != nil + }) + bh.HandleMessage(func(ctx *th.Context, message telego.Message) error { return c.handleMessage(ctx, &message) }, th.AnyMessage()) @@ -151,6 +158,28 @@ func (c *TelegramChannel) Start(ctx context.Context) error { "username": c.bot.Username(), }) + // Set Menu Button for Mini App (if WebAppURL is configured) + if webAppURL := c.config.Channels.Telegram.WebAppURL; webAppURL != "" { + menuErr := c.bot.SetChatMenuButton(ctx, &telego.SetChatMenuButtonParams{ + MenuButton: &telego.MenuButtonWebApp{ + Type: telego.ButtonTypeWebApp, + Text: "Dashboard", + WebApp: telego.WebAppInfo{ + URL: webAppURL, + }, + }, + }) + if menuErr != nil { + logger.ErrorCF("telegram", "Failed to set menu button", map[string]any{ + "error": menuErr.Error(), + }) + } else { + logger.InfoCF("telegram", "Menu button set", map[string]any{ + "url": webAppURL, + }) + } + } + go bh.Start() go func() { @@ -545,6 +574,63 @@ func (c *TelegramChannel) handleQuickCommand(ctx context.Context, message telego return nil } +// handleWebAppData processes messages sent via Telegram Mini App's sendData(). +// The data is treated as a slash command and routed through the normal message bus. +func (c *TelegramChannel) handleWebAppData(ctx context.Context, message telego.Message) error { + if message.From == nil || message.WebAppData == nil { + return nil + } + + user := message.From + senderID := fmt.Sprintf("%d", user.ID) + if user.Username != "" { + senderID = fmt.Sprintf("%d|%s", user.ID, user.Username) + } + + if !c.IsAllowed(senderID) { + logger.DebugCF("telegram", "WebAppData rejected by allowlist", map[string]any{ + "user_id": senderID, + }) + return nil + } + + content := message.WebAppData.Data + if !strings.HasPrefix(content, "/") { + logger.DebugCF("telegram", "WebAppData ignored (not a command)", map[string]any{ + "data": utils.Truncate(content, 50), + }) + return nil + } + + chatID := message.Chat.ID + + logger.InfoCF("telegram", "WebAppData command received", map[string]any{ + "user_id": senderID, + "command": utils.Truncate(content, 80), + }) + + peerKind := "direct" + peerID := fmt.Sprintf("%d", user.ID) + if message.Chat.Type != "private" { + peerKind = "group" + peerID = fmt.Sprintf("%d", chatID) + } + + metadata := map[string]string{ + "message_id": fmt.Sprintf("%d", message.MessageID), + "user_id": fmt.Sprintf("%d", user.ID), + "username": user.Username, + "first_name": user.FirstName, + "is_group": fmt.Sprintf("%t", message.Chat.Type != "private"), + "peer_kind": peerKind, + "peer_id": peerID, + "source": "webapp", + } + + c.HandleMessage(fmt.Sprintf("%d", user.ID), fmt.Sprintf("%d", chatID), content, nil, metadata) + return nil +} + func (c *TelegramChannel) downloadPhoto(ctx context.Context, fileID string) string { file, err := c.bot.GetFile(ctx, &telego.GetFileParams{FileID: fileID}) if err != nil { diff --git a/pkg/config/config.go b/pkg/config/config.go index 7ea46a512..6ce0b6ad6 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -203,10 +203,11 @@ type WhatsAppConfig struct { } type TelegramConfig struct { - Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_TELEGRAM_ENABLED"` - Token string `json:"token" env:"PICOCLAW_CHANNELS_TELEGRAM_TOKEN"` - Proxy string `json:"proxy" env:"PICOCLAW_CHANNELS_TELEGRAM_PROXY"` - AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_TELEGRAM_ALLOW_FROM"` + Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_TELEGRAM_ENABLED"` + Token string `json:"token" env:"PICOCLAW_CHANNELS_TELEGRAM_TOKEN"` + Proxy string `json:"proxy" env:"PICOCLAW_CHANNELS_TELEGRAM_PROXY"` + WebAppURL string `json:"web_app_url" env:"PICOCLAW_CHANNELS_TELEGRAM_WEB_APP_URL"` + AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_TELEGRAM_ALLOW_FROM"` } type FeishuConfig struct { diff --git a/pkg/health/server.go b/pkg/health/server.go index 77b36034d..b761a17a3 100644 --- a/pkg/health/server.go +++ b/pkg/health/server.go @@ -2,6 +2,7 @@ package health import ( "context" + "crypto/tls" "encoding/json" "fmt" "net/http" @@ -11,6 +12,7 @@ import ( type Server struct { server *http.Server + mux *http.ServeMux mu sync.RWMutex ready bool checks map[string]Check @@ -33,6 +35,7 @@ type StatusResponse struct { func NewServer(host string, port int) *Server { mux := http.NewServeMux() s := &Server{ + mux: mux, ready: false, checks: make(map[string]Check), startTime: time.Now(), @@ -52,6 +55,27 @@ func NewServer(host string, port int) *Server { return s } +// Mux returns the underlying ServeMux so additional routes can be registered. +func (s *Server) Mux() *http.ServeMux { + return s.mux +} + +// StartTLS starts the server with TLS using the provided certificate and key files. +func (s *Server) StartTLS(certFile, keyFile string) error { + s.mu.Lock() + s.ready = true + s.mu.Unlock() + + cert, err := tls.LoadX509KeyPair(certFile, keyFile) + if err != nil { + return fmt.Errorf("failed to load TLS cert: %w", err) + } + s.server.TLSConfig = &tls.Config{ + Certificates: []tls.Certificate{cert}, + } + return s.server.ListenAndServeTLS("", "") +} + func (s *Server) Start() error { s.mu.Lock() s.ready = true diff --git a/pkg/miniapp/miniapp.go b/pkg/miniapp/miniapp.go new file mode 100644 index 000000000..f074e0d69 --- /dev/null +++ b/pkg/miniapp/miniapp.go @@ -0,0 +1,160 @@ +package miniapp + +import ( + "crypto/hmac" + "crypto/sha256" + "embed" + "encoding/hex" + "encoding/json" + "fmt" + "net/http" + "net/url" + "sort" + "strings" + + "github.com/sipeed/picoclaw/pkg/skills" + "github.com/sipeed/picoclaw/pkg/stats" +) + +//go:embed static/index.html +var staticFS embed.FS + +// PlanPhase mirrors agent.PlanPhase for JSON serialization. +type PlanPhase struct { + Number int `json:"number"` + Title string `json:"title"` + Steps []PlanStep `json:"steps"` +} + +// PlanStep mirrors agent.PlanStep for JSON serialization. +type PlanStep struct { + Index int `json:"index"` + Description string `json:"description"` + Done bool `json:"done"` +} + +// PlanInfo represents the plan state exposed via the API. +type PlanInfo struct { + HasPlan bool `json:"has_plan"` + Status string `json:"status"` + CurrentPhase int `json:"current_phase"` + TotalPhases int `json:"total_phases"` + Display string `json:"display"` + Phases []PlanPhase `json:"phases"` +} + +// DataProvider is the read-only interface to agent state for the Mini App API. +type DataProvider interface { + ListSkills() []skills.SkillInfo + GetPlanInfo() PlanInfo + GetSessionStats() *stats.Stats +} + +// Handler serves the Mini App HTML and API endpoints. +type Handler struct { + provider DataProvider + botToken string +} + +// NewHandler creates a new Mini App handler. +func NewHandler(provider DataProvider, botToken string) *Handler { + return &Handler{ + provider: provider, + botToken: botToken, + } +} + +// RegisterRoutes registers Mini App routes on the given mux. +func (h *Handler) RegisterRoutes(mux *http.ServeMux) { + mux.HandleFunc("/miniapp", h.serveIndex) + mux.HandleFunc("/miniapp/api/skills", h.requireAuth(h.apiSkills)) + mux.HandleFunc("/miniapp/api/plan", h.requireAuth(h.apiPlan)) + mux.HandleFunc("/miniapp/api/session", h.requireAuth(h.apiSession)) +} + +func (h *Handler) serveIndex(w http.ResponseWriter, r *http.Request) { + data, err := staticFS.ReadFile("static/index.html") + if err != nil { + http.Error(w, "not found", http.StatusNotFound) + return + } + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.Write(data) +} + +func (h *Handler) requireAuth(next http.HandlerFunc) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + initData := r.URL.Query().Get("initData") + if initData == "" { + http.Error(w, `{"error":"missing initData"}`, http.StatusUnauthorized) + return + } + if !ValidateInitData(initData, h.botToken) { + http.Error(w, `{"error":"invalid initData"}`, http.StatusUnauthorized) + return + } + next(w, r) + } +} + +func (h *Handler) apiSkills(w http.ResponseWriter, r *http.Request) { + skillsList := h.provider.ListSkills() + writeJSON(w, skillsList) +} + +func (h *Handler) apiPlan(w http.ResponseWriter, r *http.Request) { + info := h.provider.GetPlanInfo() + writeJSON(w, info) +} + +func (h *Handler) apiSession(w http.ResponseWriter, r *http.Request) { + s := h.provider.GetSessionStats() + if s == nil { + writeJSON(w, map[string]string{"status": "stats not enabled"}) + return + } + writeJSON(w, s) +} + +func writeJSON(w http.ResponseWriter, v any) { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(v) +} + +// ValidateInitData verifies the Telegram WebApp initData HMAC-SHA256 signature. +// See https://core.telegram.org/bots/webapps#validating-data-received-via-the-mini-app +func ValidateInitData(initData, botToken string) bool { + values, err := url.ParseQuery(initData) + if err != nil { + return false + } + + receivedHash := values.Get("hash") + if receivedHash == "" { + return false + } + + // Build the data-check-string: sort all key=value pairs except "hash", + // join with newlines. + var pairs []string + for key := range values { + if key == "hash" { + continue + } + pairs = append(pairs, fmt.Sprintf("%s=%s", key, values.Get(key))) + } + sort.Strings(pairs) + dataCheckString := strings.Join(pairs, "\n") + + // secret_key = HMAC-SHA256("WebAppData", bot_token) + secretKeyMac := hmac.New(sha256.New, []byte("WebAppData")) + secretKeyMac.Write([]byte(botToken)) + secretKey := secretKeyMac.Sum(nil) + + // hash = HMAC-SHA256(secret_key, data_check_string) + hashMac := hmac.New(sha256.New, secretKey) + hashMac.Write([]byte(dataCheckString)) + computedHash := hex.EncodeToString(hashMac.Sum(nil)) + + return hmac.Equal([]byte(computedHash), []byte(receivedHash)) +} diff --git a/pkg/miniapp/miniapp_test.go b/pkg/miniapp/miniapp_test.go new file mode 100644 index 000000000..46f5904b3 --- /dev/null +++ b/pkg/miniapp/miniapp_test.go @@ -0,0 +1,99 @@ +package miniapp + +import ( + "crypto/hmac" + "crypto/sha256" + "encoding/hex" + "fmt" + "net/url" + "sort" + "strings" + "testing" +) + +// buildInitData constructs a valid initData string from params and a bot token. +func buildInitData(params map[string]string, botToken string) string { + // Build data-check-string + var pairs []string + for k, v := range params { + pairs = append(pairs, fmt.Sprintf("%s=%s", k, v)) + } + sort.Strings(pairs) + dataCheckString := strings.Join(pairs, "\n") + + // Compute secret key + secretKeyMac := hmac.New(sha256.New, []byte("WebAppData")) + secretKeyMac.Write([]byte(botToken)) + secretKey := secretKeyMac.Sum(nil) + + // Compute hash + hashMac := hmac.New(sha256.New, secretKey) + hashMac.Write([]byte(dataCheckString)) + hash := hex.EncodeToString(hashMac.Sum(nil)) + + // Build query string + values := url.Values{} + for k, v := range params { + values.Set(k, v) + } + values.Set("hash", hash) + return values.Encode() +} + +func TestValidateInitData(t *testing.T) { + botToken := "123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11" + + t.Run("valid initData", func(t *testing.T) { + params := map[string]string{ + "query_id": "AAHdF6IQAAAAAN0XohDhrOrc", + "user": `{"id":279058397,"first_name":"Vlad"}`, + "auth_date": "1234567890", + } + initData := buildInitData(params, botToken) + if !ValidateInitData(initData, botToken) { + t.Error("ValidateInitData() returned false for valid data") + } + }) + + t.Run("tampered data", func(t *testing.T) { + params := map[string]string{ + "query_id": "AAHdF6IQAAAAAN0XohDhrOrc", + "user": `{"id":279058397,"first_name":"Vlad"}`, + "auth_date": "1234567890", + } + initData := buildInitData(params, botToken) + // Tamper with the data + initData = strings.Replace(initData, "Vlad", "Evil", 1) + if ValidateInitData(initData, botToken) { + t.Error("ValidateInitData() returned true for tampered data") + } + }) + + t.Run("wrong bot token", func(t *testing.T) { + params := map[string]string{ + "auth_date": "1234567890", + } + initData := buildInitData(params, botToken) + if ValidateInitData(initData, "wrong-token") { + t.Error("ValidateInitData() returned true for wrong bot token") + } + }) + + t.Run("missing hash", func(t *testing.T) { + if ValidateInitData("auth_date=1234567890", botToken) { + t.Error("ValidateInitData() returned true for missing hash") + } + }) + + t.Run("empty initData", func(t *testing.T) { + if ValidateInitData("", botToken) { + t.Error("ValidateInitData() returned true for empty initData") + } + }) + + t.Run("invalid query string", func(t *testing.T) { + if ValidateInitData("%%%invalid", botToken) { + t.Error("ValidateInitData() returned true for invalid query string") + } + }) +} diff --git a/pkg/miniapp/static/index.html b/pkg/miniapp/static/index.html new file mode 100644 index 000000000..add8a29aa --- /dev/null +++ b/pkg/miniapp/static/index.html @@ -0,0 +1,636 @@ + + + + + +PicoClaw Dashboard + + + + + +
+ + + + +
+ +
+
Loading plan...
+ +
+ +
+
Loading skills...
+ +
+ +
+
Loading session...
+ +
+ +
+
+
Quick Commands
+
+ + + + +
+
+
+
Custom Command
+
+ + +
+
+
+ + + + + + diff --git a/pkg/tailscale/detect.go b/pkg/tailscale/detect.go new file mode 100644 index 000000000..7956d35b0 --- /dev/null +++ b/pkg/tailscale/detect.go @@ -0,0 +1,70 @@ +package tailscale + +import ( + "encoding/json" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" +) + +// tailscaleStatus is the subset of `tailscale status --json` we need. +type tailscaleStatus struct { + Self struct { + DNSName string `json:"DNSName"` + } `json:"Self"` +} + +// DetectHostname runs `tailscale status --json` and returns the machine's +// MagicDNS hostname (e.g. "machine.tailnet.ts.net"), with the trailing dot +// stripped. +func DetectHostname() (string, error) { + out, err := exec.Command("tailscale", "status", "--json").Output() + if err != nil { + return "", fmt.Errorf("tailscale status failed: %w", err) + } + + hostname, err := ParseHostname(out) + if err != nil { + return "", err + } + return hostname, nil +} + +// ParseHostname extracts the hostname from `tailscale status --json` output. +func ParseHostname(jsonData []byte) (string, error) { + var status tailscaleStatus + if err := json.Unmarshal(jsonData, &status); err != nil { + return "", fmt.Errorf("failed to parse tailscale status: %w", err) + } + + hostname := strings.TrimSuffix(status.Self.DNSName, ".") + if hostname == "" { + return "", fmt.Errorf("tailscale DNSName is empty") + } + return hostname, nil +} + +// FetchCert runs `tailscale cert` to obtain a TLS certificate for the given +// hostname. Certificates are written to certDir. Returns paths to the cert +// and key files. +func FetchCert(hostname, certDir string) (certFile, keyFile string, err error) { + if err := os.MkdirAll(certDir, 0o700); err != nil { + return "", "", fmt.Errorf("failed to create cert dir: %w", err) + } + + certFile = filepath.Join(certDir, hostname+".crt") + keyFile = filepath.Join(certDir, hostname+".key") + + cmd := exec.Command("tailscale", "cert", + "--cert-file="+certFile, + "--key-file="+keyFile, + hostname, + ) + if out, err := cmd.CombinedOutput(); err != nil { + return "", "", fmt.Errorf("tailscale cert failed: %w: %s", err, string(out)) + } + + return certFile, keyFile, nil +} diff --git a/pkg/tailscale/detect_test.go b/pkg/tailscale/detect_test.go new file mode 100644 index 000000000..7b0b9c8b5 --- /dev/null +++ b/pkg/tailscale/detect_test.go @@ -0,0 +1,59 @@ +package tailscale + +import ( + "testing" +) + +func TestParseHostname(t *testing.T) { + tests := []struct { + name string + json string + want string + wantErr bool + }{ + { + name: "valid hostname with trailing dot", + json: `{"Self":{"DNSName":"mybox.tail1234.ts.net."}}`, + want: "mybox.tail1234.ts.net", + }, + { + name: "valid hostname without trailing dot", + json: `{"Self":{"DNSName":"mybox.tail1234.ts.net"}}`, + want: "mybox.tail1234.ts.net", + }, + { + name: "empty DNSName", + json: `{"Self":{"DNSName":""}}`, + wantErr: true, + }, + { + name: "invalid JSON", + json: `not json`, + wantErr: true, + }, + { + name: "missing Self field", + json: `{}`, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := ParseHostname([]byte(tt.json)) + if tt.wantErr { + if err == nil { + t.Errorf("ParseHostname() expected error, got %q", got) + } + return + } + if err != nil { + t.Errorf("ParseHostname() unexpected error: %v", err) + return + } + if got != tt.want { + t.Errorf("ParseHostname() = %q, want %q", got, tt.want) + } + }) + } +}