From 46b099d27df7ccb11f8808a6e33dad0cb78878d4 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 01/13] 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) + } + }) + } +} From 50f50301a5a755531066814c98af3f5bd0d5cb6a Mon Sep 17 00:00:00 2001 From: dj-oyu <68707227+dj-oyu@users.noreply.github.com> Date: Sun, 22 Feb 2026 05:40:25 +0900 Subject: [PATCH 02/13] fix: pass --stats flag to AgentLoop and add plan start UI - Parse --stats flag in gatewayCmd and forward to NewAgentLoop - Add input field + Start button when plan is empty in Mini App Co-Authored-By: Claude Opus 4.6 --- cmd/picoclaw/cmd_gateway.go | 9 ++++++--- pkg/miniapp/static/index.html | 17 ++++++++++++++++- 2 files changed, 22 insertions(+), 4 deletions(-) diff --git a/cmd/picoclaw/cmd_gateway.go b/cmd/picoclaw/cmd_gateway.go index a96e86bbe..539d0d579 100644 --- a/cmd/picoclaw/cmd_gateway.go +++ b/cmd/picoclaw/cmd_gateway.go @@ -32,13 +32,16 @@ import ( ) func gatewayCmd() { - // Check for --debug flag + // Check for --debug and --stats flags args := os.Args[2:] + enableStats := false for _, arg := range args { if arg == "--debug" || arg == "-d" { logger.SetLevel(logger.DEBUG) fmt.Println("🔍 Debug mode enabled") - break + } + if arg == "--stats" { + enableStats = true } } @@ -59,7 +62,7 @@ func gatewayCmd() { } msgBus := bus.NewMessageBus() - agentLoop := agent.NewAgentLoop(cfg, msgBus, provider) + agentLoop := agent.NewAgentLoop(cfg, msgBus, provider, enableStats) // Print agent startup info fmt.Println("\n📦 Agent Status:") diff --git a/pkg/miniapp/static/index.html b/pkg/miniapp/static/index.html index add8a29aa..bb255eabb 100644 --- a/pkg/miniapp/static/index.html +++ b/pkg/miniapp/static/index.html @@ -418,6 +418,13 @@ function sendSkillCommand() { sendCommand(cmd); } +function startPlan() { + const input = document.getElementById('plan-task'); + const task = input.value.trim(); + if (!task) return; + sendCommand('/plan ' + task); +} + async function apiFetch(path) { const sep = path.includes('?') ? '&' : '?'; const res = await fetch(API_BASE + path + sep + 'initData=' + encodeURIComponent(initData)); @@ -439,7 +446,15 @@ async function loadPlan() { el.style.display = 'block'; if (!data.has_plan) { - el.innerHTML = '
No active plan.

Start one with /plan <task>
'; + el.innerHTML = + '
No active plan.
' + + '
' + + '
Start a Plan
' + + '
' + + '' + + '' + + '
' + + '
'; return; } From 5cb276d00eccfc158c0b33ac9aba5d5e06caa40f Mon Sep 17 00:00:00 2001 From: dj-oyu <68707227+dj-oyu@users.noreply.github.com> Date: Sun, 22 Feb 2026 05:45:14 +0900 Subject: [PATCH 03/13] fix: replace sendData with API POST for Mini App commands sendData() only works for Mini Apps launched via Keyboard buttons, not Menu Buttons. Replace with POST /miniapp/api/command endpoint that injects commands into the message bus via CommandSender interface. - Add CommandSender interface and POST /miniapp/api/command endpoint - Extract user ID from initData for sender identification - Replace all tg.sendData() calls with fetch POST in JS - Remove deleted /todo from Quick Commands Co-Authored-By: Claude Opus 4.6 --- cmd/picoclaw/cmd_gateway.go | 20 +++++++++- pkg/miniapp/miniapp.go | 70 ++++++++++++++++++++++++++++++++++- pkg/miniapp/static/index.html | 15 +++++--- 3 files changed, 98 insertions(+), 7 deletions(-) diff --git a/cmd/picoclaw/cmd_gateway.go b/cmd/picoclaw/cmd_gateway.go index 539d0d579..f943b35ef 100644 --- a/cmd/picoclaw/cmd_gateway.go +++ b/cmd/picoclaw/cmd_gateway.go @@ -221,7 +221,8 @@ func gatewayCmd() { if webAppURL != "" { provider := &agentLoopDataProvider{loop: agentLoop} - handler := miniapp.NewHandler(provider, cfg.Channels.Telegram.Token) + sender := &telegramCommandSender{bus: msgBus} + handler := miniapp.NewHandler(provider, sender, cfg.Channels.Telegram.Token) handler.RegisterRoutes(healthServer.Mux()) fmt.Printf("✓ Mini App registered at %s\n", webAppURL) } @@ -331,3 +332,20 @@ func (p *agentLoopDataProvider) GetPlanInfo() miniapp.PlanInfo { func (p *agentLoopDataProvider) GetSessionStats() *stats.Stats { return p.loop.GetSessionStats() } + +// telegramCommandSender injects Mini App commands into the message bus. +type telegramCommandSender struct { + bus *bus.MessageBus +} + +func (s *telegramCommandSender) SendCommand(senderID, chatID, command string) { + s.bus.PublishInbound(bus.InboundMessage{ + Channel: "telegram", + SenderID: senderID, + ChatID: chatID, + Content: command, + Metadata: map[string]string{ + "source": "webapp", + }, + }) +} diff --git a/pkg/miniapp/miniapp.go b/pkg/miniapp/miniapp.go index f074e0d69..924a5750d 100644 --- a/pkg/miniapp/miniapp.go +++ b/pkg/miniapp/miniapp.go @@ -7,6 +7,7 @@ import ( "encoding/hex" "encoding/json" "fmt" + "io" "net/http" "net/url" "sort" @@ -50,16 +51,23 @@ type DataProvider interface { GetSessionStats() *stats.Stats } +// CommandSender injects a command into the message bus on behalf of a user. +type CommandSender interface { + SendCommand(senderID, chatID, command string) +} + // Handler serves the Mini App HTML and API endpoints. type Handler struct { provider DataProvider + sender CommandSender botToken string } // NewHandler creates a new Mini App handler. -func NewHandler(provider DataProvider, botToken string) *Handler { +func NewHandler(provider DataProvider, sender CommandSender, botToken string) *Handler { return &Handler{ provider: provider, + sender: sender, botToken: botToken, } } @@ -70,6 +78,7 @@ func (h *Handler) RegisterRoutes(mux *http.ServeMux) { 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)) + mux.HandleFunc("/miniapp/api/command", h.requireAuth(h.apiCommand)) } func (h *Handler) serveIndex(w http.ResponseWriter, r *http.Request) { @@ -116,6 +125,65 @@ func (h *Handler) apiSession(w http.ResponseWriter, r *http.Request) { writeJSON(w, s) } +func (h *Handler) apiCommand(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, `{"error":"method not allowed"}`, http.StatusMethodNotAllowed) + return + } + + body, err := io.ReadAll(io.LimitReader(r.Body, 4096)) + if err != nil { + http.Error(w, `{"error":"bad request"}`, http.StatusBadRequest) + return + } + + var req struct { + Command string `json:"command"` + } + if err := json.Unmarshal(body, &req); err != nil || req.Command == "" { + http.Error(w, `{"error":"missing command"}`, http.StatusBadRequest) + return + } + + if !strings.HasPrefix(req.Command, "/") { + http.Error(w, `{"error":"command must start with /"}`, http.StatusBadRequest) + return + } + + // Extract user ID from initData to identify the sender + initData := r.URL.Query().Get("initData") + userID, chatID := extractUserFromInitData(initData) + if userID == "" { + http.Error(w, `{"error":"cannot identify user"}`, http.StatusBadRequest) + return + } + + h.sender.SendCommand(userID, chatID, req.Command) + writeJSON(w, map[string]string{"status": "ok"}) +} + +// extractUserFromInitData parses user.id from the initData query string. +// initData contains a "user" param with JSON like {"id":123456,...}. +func extractUserFromInitData(initData string) (userID, chatID string) { + values, err := url.ParseQuery(initData) + if err != nil { + return "", "" + } + userJSON := values.Get("user") + if userJSON == "" { + return "", "" + } + var user struct { + ID int64 `json:"id"` + } + if err := json.Unmarshal([]byte(userJSON), &user); err != nil || user.ID == 0 { + return "", "" + } + id := fmt.Sprintf("%d", user.ID) + // For Mini App commands, chatID = userID (private chat) + return id, id +} + func writeJSON(w http.ResponseWriter, v any) { w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(v) diff --git a/pkg/miniapp/static/index.html b/pkg/miniapp/static/index.html index bb255eabb..425e53bbf 100644 --- a/pkg/miniapp/static/index.html +++ b/pkg/miniapp/static/index.html @@ -338,7 +338,6 @@
-
@@ -389,13 +388,19 @@ document.querySelectorAll('.cmd-chip').forEach(chip => { }); }); -function sendCommand(cmd) { +async function sendCommand(cmd) { if (!cmd.startsWith('/')) return; try { - tg.sendData(cmd); + const sep = '/miniapp/api/command'.includes('?') ? '&' : '?'; + const res = await fetch(API_BASE + '/miniapp/api/command' + sep + 'initData=' + encodeURIComponent(initData), { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ command: cmd }), + }); + if (!res.ok) throw new Error('API error: ' + res.status); + tg.showAlert('Sent: ' + cmd); } catch (e) { - console.log('sendData:', cmd); - tg.showAlert('Command sent: ' + cmd); + tg.showAlert('Failed to send command'); } } From 1b789e8f7bf793372849dc11546157e36652389d Mon Sep 17 00:00:00 2001 From: dj-oyu <68707227+dj-oyu@users.noreply.github.com> Date: Sun, 22 Feb 2026 05:53:12 +0900 Subject: [PATCH 04/13] docs: add Telegram Mini App section to README Document the Dashboard Mini App with setup instructions for both Tailscale auto-detection and custom URL configurations. Co-Authored-By: Claude Opus 4.6 --- README.md | 53 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 53 insertions(+) diff --git a/README.md b/README.md index f33dc639b..aff790c58 100644 --- a/README.md +++ b/README.md @@ -306,6 +306,59 @@ Talk to your picoclaw through Telegram, Discord, DingTalk, LINE, or WeCom picoclaw gateway ``` +#### Mini App (Dashboard) + +PicoClaw includes a Telegram Mini App that provides a GUI dashboard directly inside Telegram. It shows plan progress, available skills, session stats, and lets you send commands without typing. + +**How it works:** + +When Telegram is enabled, PicoClaw automatically registers a "Dashboard" menu button in the chat. Tapping it opens the Mini App inside Telegram's WebView. + +| Tab | Description | +|-----|-------------| +| **Plan** | View plan phases/steps as a checklist, tap to mark done, start new plans | +| **Skills** | Browse and invoke skills with a message input | +| **Session** | View token usage stats (requires `--stats` flag) | +| **Config** | Quick command buttons and custom command input | + +**Setup — Tailscale (recommended for self-hosting):** + +Telegram requires HTTPS for Mini Apps. The easiest way is to use [Tailscale](https://tailscale.com/) which provides automatic TLS certificates via MagicDNS. + +1. Install Tailscale on both your server and phone +2. Allow cert provisioning (run once on the server): + ```bash + sudo tailscale set --operator=$USER + ``` +3. Start the gateway — PicoClaw auto-detects the Tailscale hostname and fetches a TLS certificate: + ```bash + picoclaw gateway --stats + ``` + You should see: + ``` + ✓ Mini App registered at https://..ts.net:18790/miniapp + ``` + +> Your phone must also be connected to the same Tailnet to access the Mini App. + +**Setup — Custom URL:** + +If you already have an HTTPS endpoint (e.g., reverse proxy, Cloudflare Tunnel), set `web_app_url` manually: + +```json +{ + "channels": { + "telegram": { + "enabled": true, + "token": "YOUR_BOT_TOKEN", + "web_app_url": "https://your-domain.com/miniapp" + } + } +} +``` + +When `web_app_url` is set, PicoClaw skips Tailscale auto-detection and serves the Mini App over HTTP (your reverse proxy handles TLS). +
From 51108915e85873137a90fe57c760c435ca2c1854 Mon Sep 17 00:00:00 2001 From: dj-oyu <68707227+dj-oyu@users.noreply.github.com> Date: Sun, 22 Feb 2026 05:56:19 +0900 Subject: [PATCH 05/13] fix: replace alert dialogs with visual feedback for command buttons - Chip buttons flash theme color briefly on success - Send buttons flash green on success - Remove showAlert for all commands except errors and plan start - Add scale-down on press for tactile feel Co-Authored-By: Claude Opus 4.6 --- pkg/miniapp/static/index.html | 48 +++++++++++++++++++++++++---------- 1 file changed, 34 insertions(+), 14 deletions(-) diff --git a/pkg/miniapp/static/index.html b/pkg/miniapp/static/index.html index 425e53bbf..b668e87f7 100644 --- a/pkg/miniapp/static/index.html +++ b/pkg/miniapp/static/index.html @@ -255,6 +255,7 @@ .send-btn:active { opacity: 0.8; } .send-btn:disabled { opacity: 0.5; } + .send-btn.sent { background: var(--done); transition: background 0.15s; } .empty-state { text-align: center; @@ -287,10 +288,13 @@ transition: all 0.2s; } - .cmd-chip:active { + .cmd-chip:active { transform: scale(0.93); } + + .cmd-chip.sent { background: var(--btn); color: var(--btn-text); border-color: var(--btn); + transition: all 0.15s; } .refresh-btn { @@ -383,51 +387,67 @@ document.querySelectorAll('.tab').forEach(tab => { // Quick command chips document.querySelectorAll('.cmd-chip').forEach(chip => { - chip.addEventListener('click', () => { - sendCommand(chip.dataset.cmd); + chip.addEventListener('click', async () => { + const ok = await sendCommand(chip.dataset.cmd); + if (ok) flashSent(chip); }); }); async function sendCommand(cmd) { - if (!cmd.startsWith('/')) return; + if (!cmd.startsWith('/')) return false; try { - const sep = '/miniapp/api/command'.includes('?') ? '&' : '?'; - const res = await fetch(API_BASE + '/miniapp/api/command' + sep + 'initData=' + encodeURIComponent(initData), { + const res = await fetch(API_BASE + '/miniapp/api/command?initData=' + encodeURIComponent(initData), { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ command: cmd }), }); if (!res.ok) throw new Error('API error: ' + res.status); - tg.showAlert('Sent: ' + cmd); + return true; } catch (e) { tg.showAlert('Failed to send command'); + return false; } } -function sendCustomCmd() { +async function sendCustomCmd() { const input = document.getElementById('custom-cmd'); + const btn = input.nextElementSibling; const cmd = input.value.trim(); if (!cmd) return; if (!cmd.startsWith('/')) { tg.showAlert('Command must start with /'); return; } - sendCommand(cmd); - input.value = ''; + const ok = await sendCommand(cmd); + if (ok) { + input.value = ''; + flashSent(btn); + } } -function sendSkillCommand() { +async function sendSkillCommand() { if (!selectedSkill) return; const msg = document.getElementById('skill-msg').value.trim(); const cmd = msg ? '/skill ' + selectedSkill + ' ' + msg : '/skill ' + selectedSkill; - sendCommand(cmd); + const btn = document.getElementById('send-skill-btn'); + const ok = await sendCommand(cmd); + if (ok) flashSent(btn); } -function startPlan() { +async function startPlan() { const input = document.getElementById('plan-task'); const task = input.value.trim(); if (!task) return; - sendCommand('/plan ' + task); + const ok = await sendCommand('/plan ' + task); + if (ok) { + input.value = ''; + tg.showAlert('Plan started!'); + } +} + +function flashSent(el) { + el.classList.add('sent'); + setTimeout(() => el.classList.remove('sent'), 600); } async function apiFetch(path) { From 256d80bf9a180b3eaad851af6495420a249a5740 Mon Sep 17 00:00:00 2001 From: dj-oyu <68707227+dj-oyu@users.noreply.github.com> Date: Sun, 22 Feb 2026 06:00:41 +0900 Subject: [PATCH 06/13] fix: change /plan status chip to /plan to avoid accidental plan creation /plan status falls through to the default case in handlePlanCommand, which treats "status" as a task description and starts a new plan. The correct command to show plan progress is just /plan (no args). Co-Authored-By: Claude Opus 4.6 --- pkg/miniapp/static/index.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/miniapp/static/index.html b/pkg/miniapp/static/index.html index b668e87f7..bc51b259d 100644 --- a/pkg/miniapp/static/index.html +++ b/pkg/miniapp/static/index.html @@ -342,7 +342,7 @@
- +
From 12887a3177301fd5834c840d8f08fc29acfc3999 Mon Sep 17 00:00:00 2001 From: dj-oyu <68707227+dj-oyu@users.noreply.github.com> Date: Sun, 22 Feb 2026 06:01:34 +0900 Subject: [PATCH 07/13] fix: change /plan chip to /plan clear Co-Authored-By: Claude Opus 4.6 --- pkg/miniapp/static/index.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/miniapp/static/index.html b/pkg/miniapp/static/index.html index bc51b259d..2902fbc50 100644 --- a/pkg/miniapp/static/index.html +++ b/pkg/miniapp/static/index.html @@ -342,7 +342,7 @@
- +
From ff38deb470aa97f8a371846d44b0b3f866e2a286 Mon Sep 17 00:00:00 2001 From: dj-oyu <68707227+dj-oyu@users.noreply.github.com> Date: Sun, 22 Feb 2026 06:15:44 +0900 Subject: [PATCH 08/13] fix: remove all showAlert dialogs, use visual feedback only Co-Authored-By: Claude Opus 4.6 --- pkg/miniapp/static/index.html | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/pkg/miniapp/static/index.html b/pkg/miniapp/static/index.html index 2902fbc50..338dfeb54 100644 --- a/pkg/miniapp/static/index.html +++ b/pkg/miniapp/static/index.html @@ -404,7 +404,6 @@ async function sendCommand(cmd) { if (!res.ok) throw new Error('API error: ' + res.status); return true; } catch (e) { - tg.showAlert('Failed to send command'); return false; } } @@ -414,10 +413,7 @@ async function sendCustomCmd() { const btn = input.nextElementSibling; const cmd = input.value.trim(); if (!cmd) return; - if (!cmd.startsWith('/')) { - tg.showAlert('Command must start with /'); - return; - } + if (!cmd.startsWith('/')) return; const ok = await sendCommand(cmd); if (ok) { input.value = ''; @@ -436,12 +432,13 @@ async function sendSkillCommand() { async function startPlan() { const input = document.getElementById('plan-task'); + const btn = input.nextElementSibling; const task = input.value.trim(); if (!task) return; const ok = await sendCommand('/plan ' + task); if (ok) { input.value = ''; - tg.showAlert('Plan started!'); + flashSent(btn); } } From f78ee42e90a70e6c76e7e787d731757995a8a663 Mon Sep 17 00:00:00 2001 From: dj-oyu <68707227+dj-oyu@users.noreply.github.com> Date: Sun, 22 Feb 2026 06:21:36 +0900 Subject: [PATCH 09/13] =?UTF-8?q?feat:=20improve=20tap=20affordances=20?= =?UTF-8?q?=E2=80=94=20tiles,=20arrows,=20and=20larger=20touch=20targets?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Quick Commands: small chips → large 2-column tile grid - Skills: add right arrow indicator and tinted background on selection - Plan steps: undone steps get card background, done steps are flat - Refresh button: full-width block button - All tappable elements: scale-down on press for tactile feedback Co-Authored-By: Claude Opus 4.6 --- pkg/miniapp/static/index.html | 114 ++++++++++++++++++++-------------- 1 file changed, 67 insertions(+), 47 deletions(-) diff --git a/pkg/miniapp/static/index.html b/pkg/miniapp/static/index.html index 338dfeb54..84f4b4623 100644 --- a/pkg/miniapp/static/index.html +++ b/pkg/miniapp/static/index.html @@ -121,14 +121,15 @@ display: flex; align-items: flex-start; gap: 10px; - padding: 8px 0 8px 30px; - border-bottom: 1px solid var(--secondary-bg); + padding: 10px 12px 10px 30px; + margin-bottom: 4px; + border-radius: 8px; cursor: pointer; - transition: opacity 0.15s; + transition: all 0.15s; } - .step:last-child { border-bottom: none; } - .step:active { opacity: 0.6; } + .step:active { transform: scale(0.97); } + .step:not(.step-done) { background: var(--secondary-bg); } .step-check { width: 20px; @@ -172,15 +173,20 @@ .skill-item { background: var(--secondary-bg); border-radius: 12px; - padding: 14px; + padding: 14px 14px 14px 16px; margin-bottom: 8px; cursor: pointer; - transition: opacity 0.2s; - border-left: 3px solid transparent; + transition: all 0.15s; + border-left: 3px solid var(--hint); + display: flex; + align-items: center; + gap: 12px; } - .skill-item:active { opacity: 0.7; } - .skill-item.selected { border-left-color: var(--btn); } + .skill-item:active { transform: scale(0.97); } + .skill-item.selected { border-left-color: var(--btn); background: color-mix(in srgb, var(--btn) 10%, var(--secondary-bg)); } + + .skill-body { flex: 1; min-width: 0; } .skill-name { font-weight: 600; @@ -204,6 +210,14 @@ margin-top: 6px; } + .skill-arrow { + color: var(--hint); + font-size: 18px; + flex-shrink: 0; + transition: color 0.15s; + } + .skill-item.selected .skill-arrow { color: var(--btn); } + /* Stats */ .stat-row { display: flex; @@ -270,46 +284,48 @@ padding: 40px 20px; } - .cmd-chips { - display: flex; - flex-wrap: wrap; - gap: 8px; - margin-bottom: 12px; + .cmd-tiles { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 10px; } - .cmd-chip { - padding: 6px 14px; - border-radius: 16px; - border: 1px solid var(--hint); - background: var(--bg); + .cmd-tile { + padding: 16px 14px; + border-radius: 12px; + border: none; + background: var(--secondary-bg); color: var(--text); - font-size: 13px; + font-size: 15px; + font-weight: 500; cursor: pointer; - transition: all 0.2s; + transition: all 0.15s; + text-align: center; } - .cmd-chip:active { transform: scale(0.93); } + .cmd-tile:active { transform: scale(0.95); } - .cmd-chip.sent { + .cmd-tile.sent { background: var(--btn); color: var(--btn-text); - border-color: var(--btn); transition: all 0.15s; } .refresh-btn { - padding: 8px 16px; - border-radius: 8px; - border: 1px solid var(--hint); - background: var(--bg); - color: var(--text); - font-size: 13px; + display: block; + width: 100%; + padding: 12px 16px; + border-radius: 12px; + border: none; + background: var(--secondary-bg); + color: var(--hint); + font-size: 14px; cursor: pointer; - float: right; - margin-bottom: 8px; + margin-top: 8px; + transition: all 0.15s; } - .refresh-btn:active { opacity: 0.7; } + .refresh-btn:active { transform: scale(0.97); } @@ -339,10 +355,10 @@
Quick Commands
-
- - - +
+ + +
@@ -385,11 +401,11 @@ document.querySelectorAll('.tab').forEach(tab => { }); }); -// Quick command chips -document.querySelectorAll('.cmd-chip').forEach(chip => { - chip.addEventListener('click', async () => { - const ok = await sendCommand(chip.dataset.cmd); - if (ok) flashSent(chip); +// Quick command tiles +document.querySelectorAll('.cmd-tile').forEach(tile => { + tile.addEventListener('click', async () => { + const ok = await sendCommand(tile.dataset.cmd); + if (ok) flashSent(tile); }); }); @@ -536,7 +552,8 @@ function renderPhases(phases, currentPhase) { for (const step of phase.steps) { const checkClass = step.done ? 'done' : ''; const textClass = step.done ? 'done' : ''; - html += '
'; + const stepClass = step.done ? 'step step-done' : 'step'; + html += '
'; html += '
'; html += '
' + escapeHtml(step.description) + '
'; html += '
'; @@ -579,9 +596,12 @@ async function loadSkills() { el.innerHTML = data.map(s => '
' + - '
' + escapeHtml(s.name) + '
' + - '
' + escapeHtml(s.description || 'No description') + '
' + - '' + escapeHtml(s.source) + '' + + '
' + + '
' + escapeHtml(s.name) + '
' + + '
' + escapeHtml(s.description || 'No description') + '
' + + '' + escapeHtml(s.source) + '' + + '
' + + '\u203A' + '
' ).join(''); From 7594ef18bf3fc89a2f1c3dd51ed6790e3143c4f4 Mon Sep 17 00:00:00 2001 From: dj-oyu <68707227+dj-oyu@users.noreply.github.com> Date: Sun, 22 Feb 2026 06:29:51 +0900 Subject: [PATCH 10/13] fix: add visible borders to tiles and skill cards for tap affordance Tiles and skill items had secondary-bg background on secondary-bg parent, making them invisible as buttons. Switch to bg background with hint-color borders so they stand out as distinct tappable areas. Co-Authored-By: Claude Opus 4.6 --- pkg/miniapp/static/index.html | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/pkg/miniapp/static/index.html b/pkg/miniapp/static/index.html index 84f4b4623..7a0cefea5 100644 --- a/pkg/miniapp/static/index.html +++ b/pkg/miniapp/static/index.html @@ -171,20 +171,20 @@ /* Skills */ .skill-item { - background: var(--secondary-bg); + background: var(--bg); border-radius: 12px; padding: 14px 14px 14px 16px; - margin-bottom: 8px; + margin-bottom: 10px; cursor: pointer; transition: all 0.15s; - border-left: 3px solid var(--hint); + border: 1.5px solid var(--hint); display: flex; align-items: center; gap: 12px; } .skill-item:active { transform: scale(0.97); } - .skill-item.selected { border-left-color: var(--btn); background: color-mix(in srgb, var(--btn) 10%, var(--secondary-bg)); } + .skill-item.selected { border-color: var(--btn); background: color-mix(in srgb, var(--btn) 8%, var(--bg)); } .skill-body { flex: 1; min-width: 0; } @@ -212,7 +212,7 @@ .skill-arrow { color: var(--hint); - font-size: 18px; + font-size: 22px; flex-shrink: 0; transition: color 0.15s; } @@ -293,21 +293,22 @@ .cmd-tile { padding: 16px 14px; border-radius: 12px; - border: none; - background: var(--secondary-bg); + border: 1.5px solid var(--hint); + background: var(--bg); color: var(--text); font-size: 15px; - font-weight: 500; + font-weight: 600; cursor: pointer; transition: all 0.15s; text-align: center; } - .cmd-tile:active { transform: scale(0.95); } + .cmd-tile:active { transform: scale(0.95); background: var(--secondary-bg); } .cmd-tile.sent { background: var(--btn); color: var(--btn-text); + border-color: var(--btn); transition: all 0.15s; } From c443289877e362aeb64ebc88ee6dfbca076d5604 Mon Sep 17 00:00:00 2001 From: dj-oyu <68707227+dj-oyu@users.noreply.github.com> Date: Sun, 22 Feb 2026 06:43:17 +0900 Subject: [PATCH 11/13] fix: add dark mode fallbacks and remove color-mix for compatibility - Add @media (prefers-color-scheme: dark) with dark fallback values for when Telegram theme variables are not available (testing/preview) - Remove color-mix() for skill selection highlight (not supported in all WebView versions) - All runtime colors use Telegram theme variables which automatically adapt to light/dark mode Co-Authored-By: Claude Opus 4.6 --- pkg/miniapp/static/index.html | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/pkg/miniapp/static/index.html b/pkg/miniapp/static/index.html index 7a0cefea5..aa722e737 100644 --- a/pkg/miniapp/static/index.html +++ b/pkg/miniapp/static/index.html @@ -19,6 +19,18 @@ --pending-phase: var(--hint); } + @media (prefers-color-scheme: dark) { + :root { + --bg: var(--tg-theme-bg-color, #1c1c1e); + --text: var(--tg-theme-text-color, #ffffff); + --hint: var(--tg-theme-hint-color, #8e8e93); + --link: var(--tg-theme-link-color, #5ac8fa); + --btn: var(--tg-theme-button-color, #5ac8fa); + --btn-text: var(--tg-theme-button-text-color, #ffffff); + --secondary-bg: var(--tg-theme-secondary-bg-color, #2c2c2e); + } + } + * { box-sizing: border-box; margin: 0; padding: 0; } body { @@ -184,7 +196,7 @@ } .skill-item:active { transform: scale(0.97); } - .skill-item.selected { border-color: var(--btn); background: color-mix(in srgb, var(--btn) 8%, var(--bg)); } + .skill-item.selected { border-color: var(--btn); } .skill-body { flex: 1; min-width: 0; } From e476b5b43accadb3729d00be03a1fda464f6fcbd Mon Sep 17 00:00:00 2001 From: dj-oyu <68707227+dj-oyu@users.noreply.github.com> Date: Sun, 22 Feb 2026 06:54:32 +0900 Subject: [PATCH 12/13] feat: add raised button effect with gradient and shadow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Gradient background (bg → secondary-bg) gives depth - box-shadow lifts elements off the surface - Press: shadow disappears + translateY(1px) for physical push feel - Applied to: command tiles, skill cards, refresh button Co-Authored-By: Claude Opus 4.6 --- pkg/miniapp/static/index.html | 40 +++++++++++++++++++++++------------ 1 file changed, 27 insertions(+), 13 deletions(-) diff --git a/pkg/miniapp/static/index.html b/pkg/miniapp/static/index.html index aa722e737..204e4a0b0 100644 --- a/pkg/miniapp/static/index.html +++ b/pkg/miniapp/static/index.html @@ -183,20 +183,24 @@ /* Skills */ .skill-item { - background: var(--bg); + background: linear-gradient(to bottom, var(--bg), var(--secondary-bg)); border-radius: 12px; padding: 14px 14px 14px 16px; margin-bottom: 10px; cursor: pointer; - transition: all 0.15s; - border: 1.5px solid var(--hint); + transition: all 0.12s; + border: 1px solid var(--hint); display: flex; align-items: center; gap: 12px; + box-shadow: 0 2px 4px rgba(0,0,0,0.08), 0 1px 0 rgba(255,255,255,0.06) inset; } - .skill-item:active { transform: scale(0.97); } - .skill-item.selected { border-color: var(--btn); } + .skill-item:active { + transform: scale(0.98) translateY(1px); + box-shadow: 0 0 2px rgba(0,0,0,0.06); + } + .skill-item.selected { border-color: var(--btn); box-shadow: 0 2px 6px rgba(0,0,0,0.12); } .skill-body { flex: 1; min-width: 0; } @@ -305,22 +309,28 @@ .cmd-tile { padding: 16px 14px; border-radius: 12px; - border: 1.5px solid var(--hint); - background: var(--bg); + border: 1px solid var(--hint); + background: linear-gradient(to bottom, var(--bg), var(--secondary-bg)); color: var(--text); font-size: 15px; font-weight: 600; cursor: pointer; - transition: all 0.15s; + transition: all 0.12s; text-align: center; + box-shadow: 0 2px 4px rgba(0,0,0,0.1), 0 1px 0 rgba(255,255,255,0.06) inset; } - .cmd-tile:active { transform: scale(0.95); background: var(--secondary-bg); } + .cmd-tile:active { + transform: scale(0.96) translateY(1px); + box-shadow: 0 0 2px rgba(0,0,0,0.08); + background: var(--secondary-bg); + } .cmd-tile.sent { background: var(--btn); color: var(--btn-text); border-color: var(--btn); + box-shadow: 0 2px 4px rgba(0,0,0,0.15); transition: all 0.15s; } @@ -329,16 +339,20 @@ width: 100%; padding: 12px 16px; border-radius: 12px; - border: none; - background: var(--secondary-bg); + border: 1px solid var(--hint); + background: linear-gradient(to bottom, var(--bg), var(--secondary-bg)); color: var(--hint); font-size: 14px; cursor: pointer; margin-top: 8px; - transition: all 0.15s; + transition: all 0.12s; + box-shadow: 0 2px 4px rgba(0,0,0,0.08); } - .refresh-btn:active { transform: scale(0.97); } + .refresh-btn:active { + transform: scale(0.98) translateY(1px); + box-shadow: 0 0 2px rgba(0,0,0,0.06); + } From 0138a47fcb34ed7d99a2b92bac72e6d59363efd5 Mon Sep 17 00:00:00 2001 From: dj-oyu <68707227+dj-oyu@users.noreply.github.com> Date: Sun, 22 Feb 2026 06:57:18 +0900 Subject: [PATCH 13/13] fix: use text color for Refresh button instead of hint Co-Authored-By: Claude Opus 4.6 --- pkg/miniapp/static/index.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/miniapp/static/index.html b/pkg/miniapp/static/index.html index 204e4a0b0..e8e287534 100644 --- a/pkg/miniapp/static/index.html +++ b/pkg/miniapp/static/index.html @@ -341,7 +341,7 @@ border-radius: 12px; border: 1px solid var(--hint); background: linear-gradient(to bottom, var(--bg), var(--secondary-bg)); - color: var(--hint); + color: var(--text); font-size: 14px; cursor: pointer; margin-top: 8px;