Add Close/Done to StateNotifier so SSE handlers exit on server shutdown instead of blocking indefinitely. Remove tg.expand() to allow MiniApp to open at Telegram's default partial height. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
362 lines
9.8 KiB
Go
362 lines
9.8 KiB
Go
package miniapp
|
|
|
|
import (
|
|
"bytes"
|
|
"crypto/hmac"
|
|
"crypto/sha256"
|
|
"embed"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/url"
|
|
"sort"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"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"`
|
|
}
|
|
|
|
// SessionInfo represents an active session entry for the API response.
|
|
type SessionInfo struct {
|
|
SessionKey string `json:"session_key"`
|
|
Channel string `json:"channel"`
|
|
ChatID string `json:"chat_id"`
|
|
TouchDir string `json:"touch_dir"`
|
|
LastSeenAt string `json:"last_seen_at"`
|
|
AgeSec int `json:"age_sec"`
|
|
}
|
|
|
|
// 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
|
|
GetActiveSessions() []SessionInfo
|
|
}
|
|
|
|
// CommandSender injects a command into the message bus on behalf of a user.
|
|
type CommandSender interface {
|
|
SendCommand(senderID, chatID, command string)
|
|
}
|
|
|
|
// StateNotifier broadcasts state-change signals to SSE subscribers.
|
|
type StateNotifier struct {
|
|
mu sync.Mutex
|
|
subs map[chan struct{}]struct{}
|
|
done chan struct{}
|
|
}
|
|
|
|
// NewStateNotifier creates a new StateNotifier.
|
|
func NewStateNotifier() *StateNotifier {
|
|
return &StateNotifier{
|
|
subs: make(map[chan struct{}]struct{}),
|
|
done: make(chan struct{}),
|
|
}
|
|
}
|
|
|
|
// Subscribe returns a channel that receives a signal on each state change.
|
|
func (n *StateNotifier) Subscribe() chan struct{} {
|
|
ch := make(chan struct{}, 1)
|
|
n.mu.Lock()
|
|
n.subs[ch] = struct{}{}
|
|
n.mu.Unlock()
|
|
return ch
|
|
}
|
|
|
|
// Unsubscribe removes a subscriber channel.
|
|
func (n *StateNotifier) Unsubscribe(ch chan struct{}) {
|
|
n.mu.Lock()
|
|
delete(n.subs, ch)
|
|
n.mu.Unlock()
|
|
}
|
|
|
|
// Close signals all SSE handlers to exit.
|
|
func (n *StateNotifier) Close() {
|
|
select {
|
|
case <-n.done:
|
|
default:
|
|
close(n.done)
|
|
}
|
|
}
|
|
|
|
// Done returns a channel that is closed when the notifier is shut down.
|
|
func (n *StateNotifier) Done() <-chan struct{} {
|
|
return n.done
|
|
}
|
|
|
|
// Notify sends a signal to all subscribers, coalescing rapid notifications.
|
|
func (n *StateNotifier) Notify() {
|
|
n.mu.Lock()
|
|
defer n.mu.Unlock()
|
|
for ch := range n.subs {
|
|
select {
|
|
case ch <- struct{}{}:
|
|
default:
|
|
}
|
|
}
|
|
}
|
|
|
|
// Handler serves the Mini App HTML and API endpoints.
|
|
type Handler struct {
|
|
provider DataProvider
|
|
sender CommandSender
|
|
botToken string
|
|
notifier *StateNotifier
|
|
}
|
|
|
|
// NewHandler creates a new Mini App handler.
|
|
func NewHandler(provider DataProvider, sender CommandSender, botToken string, notifier *StateNotifier) *Handler {
|
|
return &Handler{
|
|
provider: provider,
|
|
sender: sender,
|
|
botToken: botToken,
|
|
notifier: notifier,
|
|
}
|
|
}
|
|
|
|
// 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))
|
|
mux.HandleFunc("/miniapp/api/sessions", h.requireAuth(h.apiSessions))
|
|
mux.HandleFunc("/miniapp/api/command", h.requireAuth(h.apiCommand))
|
|
mux.HandleFunc("/miniapp/api/events", h.requireAuth(h.apiEvents))
|
|
}
|
|
|
|
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) apiSessions(w http.ResponseWriter, r *http.Request) {
|
|
sessions := h.provider.GetActiveSessions()
|
|
if sessions == nil {
|
|
sessions = []SessionInfo{}
|
|
}
|
|
writeJSON(w, sessions)
|
|
}
|
|
|
|
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 (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 (h *Handler) apiEvents(w http.ResponseWriter, r *http.Request) {
|
|
flusher, ok := w.(http.Flusher)
|
|
if !ok {
|
|
http.Error(w, `{"error":"streaming not supported"}`, http.StatusInternalServerError)
|
|
return
|
|
}
|
|
rc := http.NewResponseController(w)
|
|
_ = rc.SetWriteDeadline(time.Time{})
|
|
|
|
w.Header().Set("Content-Type", "text/event-stream")
|
|
w.Header().Set("Cache-Control", "no-cache")
|
|
w.Header().Set("Connection", "keep-alive")
|
|
w.Header().Set("X-Accel-Buffering", "no")
|
|
|
|
ch := h.notifier.Subscribe()
|
|
defer h.notifier.Unsubscribe(ch)
|
|
|
|
var lastPlan, lastSession, lastSkills []byte
|
|
|
|
// Send initial state immediately
|
|
sendSSEIfChanged(w, flusher, "plan", h.provider.GetPlanInfo(), &lastPlan)
|
|
sendSSEIfChanged(w, flusher, "session",
|
|
map[string]any{"stats": h.provider.GetSessionStats(), "sessions": h.provider.GetActiveSessions()},
|
|
&lastSession)
|
|
sendSSEIfChanged(w, flusher, "skills", h.provider.ListSkills(), &lastSkills)
|
|
|
|
for {
|
|
select {
|
|
case <-r.Context().Done():
|
|
return
|
|
case <-h.notifier.Done():
|
|
return
|
|
case <-ch:
|
|
sendSSEIfChanged(w, flusher, "plan", h.provider.GetPlanInfo(), &lastPlan)
|
|
sendSSEIfChanged(w, flusher, "session",
|
|
map[string]any{"stats": h.provider.GetSessionStats(), "sessions": h.provider.GetActiveSessions()},
|
|
&lastSession)
|
|
sendSSEIfChanged(w, flusher, "skills", h.provider.ListSkills(), &lastSkills)
|
|
}
|
|
}
|
|
}
|
|
|
|
func sendSSEIfChanged(w http.ResponseWriter, f http.Flusher, event string, v any, last *[]byte) {
|
|
data, _ := json.Marshal(v)
|
|
if !bytes.Equal(data, *last) {
|
|
fmt.Fprintf(w, "event: %s\ndata: %s\n\n", event, data)
|
|
f.Flush()
|
|
*last = data
|
|
}
|
|
}
|
|
|
|
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))
|
|
}
|