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 <noreply@anthropic.com>
This commit is contained in:
dj-oyu 2026-02-22 05:45:14 +09:00
parent 866b1a67a9
commit fbb1e7ea03
3 changed files with 98 additions and 7 deletions

View file

@ -221,7 +221,8 @@ func gatewayCmd() {
if webAppURL != "" { if webAppURL != "" {
provider := &agentLoopDataProvider{loop: agentLoop} 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()) handler.RegisterRoutes(healthServer.Mux())
fmt.Printf("✓ Mini App registered at %s\n", webAppURL) 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 { func (p *agentLoopDataProvider) GetSessionStats() *stats.Stats {
return p.loop.GetSessionStats() 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",
},
})
}

View file

@ -7,6 +7,7 @@ import (
"encoding/hex" "encoding/hex"
"encoding/json" "encoding/json"
"fmt" "fmt"
"io"
"net/http" "net/http"
"net/url" "net/url"
"sort" "sort"
@ -50,16 +51,23 @@ type DataProvider interface {
GetSessionStats() *stats.Stats 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. // Handler serves the Mini App HTML and API endpoints.
type Handler struct { type Handler struct {
provider DataProvider provider DataProvider
sender CommandSender
botToken string botToken string
} }
// NewHandler creates a new Mini App handler. // 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{ return &Handler{
provider: provider, provider: provider,
sender: sender,
botToken: botToken, 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/skills", h.requireAuth(h.apiSkills))
mux.HandleFunc("/miniapp/api/plan", h.requireAuth(h.apiPlan)) mux.HandleFunc("/miniapp/api/plan", h.requireAuth(h.apiPlan))
mux.HandleFunc("/miniapp/api/session", h.requireAuth(h.apiSession)) 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) { 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) 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) { func writeJSON(w http.ResponseWriter, v any) {
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(v) json.NewEncoder(w).Encode(v)

View file

@ -338,7 +338,6 @@
<div class="cmd-chips"> <div class="cmd-chips">
<button class="cmd-chip" data-cmd="/session">/session</button> <button class="cmd-chip" data-cmd="/session">/session</button>
<button class="cmd-chip" data-cmd="/skills">/skills</button> <button class="cmd-chip" data-cmd="/skills">/skills</button>
<button class="cmd-chip" data-cmd="/todo">/todo</button>
<button class="cmd-chip" data-cmd="/plan status">/plan status</button> <button class="cmd-chip" data-cmd="/plan status">/plan status</button>
</div> </div>
</div> </div>
@ -389,13 +388,19 @@ document.querySelectorAll('.cmd-chip').forEach(chip => {
}); });
}); });
function sendCommand(cmd) { async function sendCommand(cmd) {
if (!cmd.startsWith('/')) return; if (!cmd.startsWith('/')) return;
try { 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) { } catch (e) {
console.log('sendData:', cmd); tg.showAlert('Failed to send command');
tg.showAlert('Command sent: ' + cmd);
} }
} }