// Command walletminiapp is a runnable Mini App demo for telesrv bot developers. // // It intentionally behaves like an external bot program: it serves the Mini App // over HTTP and configures telesrv through the Bot API gateway. It does not write // directly to telesrv stores, so the demo remains close to a real integration. package main import ( "bytes" "context" "crypto/hmac" "crypto/sha256" "encoding/hex" "encoding/json" "errors" "flag" "fmt" "io" "log" "net/http" "net/url" "os" "os/signal" "sort" "strconv" "strings" "sync" "syscall" "time" ) const ( defaultBalanceCents = int64(125000) maxMemoLen = 180 maxTransferCents = int64(500000) ) type config struct { listen string publicURL string botAPI string token string menuText string register bool initMax time.Duration } type appServer struct { cfg config botapi *botAPIClient wallet *walletStore log *log.Logger } func main() { cfg := config{} flag.StringVar(&cfg.listen, "listen", envOr("TELESRV_WALLET_LISTEN", "127.0.0.1:8091"), "wallet mini app HTTP listen address") flag.StringVar(&cfg.publicURL, "public-url", os.Getenv("TELESRV_WALLET_PUBLIC_URL"), "public HTTPS URL used in the Telegram menu button") flag.StringVar(&cfg.botAPI, "bot-api", envOr("TELESRV_BOT_API_URL", "http://127.0.0.1:8081"), "telesrv Bot API base URL") flag.StringVar(&cfg.token, "token", os.Getenv("TELESRV_BOT_TOKEN"), "bot token :") flag.StringVar(&cfg.menuText, "menu-text", envOr("TELESRV_WALLET_MENU_TEXT", "Wallet"), "menu button label") flag.BoolVar(&cfg.register, "register", true, "call Bot API setChatMenuButton on startup when token and public-url are set") flag.DurationVar(&cfg.initMax, "init-max-age", 24*time.Hour, "maximum accepted tgWebAppData age, 0 disables age checks") flag.Parse() logger := log.New(os.Stdout, "walletminiapp ", log.LstdFlags|log.Lmicroseconds) if cfg.publicURL == "" { cfg.publicURL = "http://" + cfg.listen + "/" } ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) defer stop() app := &appServer{ cfg: cfg, botapi: newBotAPIClient(cfg.botAPI, cfg.token), wallet: newWalletStore(), log: logger, } if err := app.configureBotMenu(ctx); err != nil { logger.Fatalf("configure bot menu: %v", err) } srv := &http.Server{ Addr: cfg.listen, Handler: app.routes(), ReadHeaderTimeout: 5 * time.Second, } go func() { <-ctx.Done() shutdownCtx, cancel := context.WithTimeout(context.Background(), 3*time.Second) defer cancel() _ = srv.Shutdown(shutdownCtx) }() logger.Printf("serving wallet mini app on http://%s/", cfg.listen) logger.Printf("configured public URL: %s", cfg.publicURL) if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { logger.Fatalf("serve: %v", err) } } func (s *appServer) configureBotMenu(ctx context.Context) error { if !s.cfg.register { s.log.Printf("bot menu registration disabled") return nil } if strings.TrimSpace(s.cfg.token) == "" { s.log.Printf("bot token missing; serving UI only, Bot API calls disabled") return nil } if !isHTTPSURL(s.cfg.publicURL) { s.log.Printf("public-url is not HTTPS; serving UI only, skip setChatMenuButton") return nil } ctx, cancel := context.WithTimeout(ctx, 5*time.Second) defer cancel() if err := s.botapi.setChatMenuButton(ctx, s.cfg.menuText, s.cfg.publicURL); err != nil { return err } s.log.Printf("registered menu button %q -> %s", s.cfg.menuText, s.cfg.publicURL) return nil } func (s *appServer) routes() http.Handler { mux := http.NewServeMux() mux.HandleFunc("/healthz", func(w http.ResponseWriter, _ *http.Request) { writeJSON(w, http.StatusOK, map[string]any{"ok": true}) }) mux.HandleFunc("/api/session", s.handleSession) mux.HandleFunc("/api/transfer", s.handleTransfer) mux.HandleFunc("/api/share-prepared", s.handleSharePrepared) mux.HandleFunc("/api/answer-webapp-query", s.handleAnswerWebAppQuery) mux.HandleFunc("/api/payment-intent", s.handlePaymentIntent) mux.HandleFunc("/", s.handleIndex) return withSecurityHeaders(mux) } func withSecurityHeaders(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("X-Content-Type-Options", "nosniff") w.Header().Set("Referrer-Policy", "no-referrer") w.Header().Set("Permissions-Policy", "camera=(), microphone=(), geolocation=()") next.ServeHTTP(w, r) }) } func (s *appServer) handleIndex(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet && r.Method != http.MethodHead { writeError(w, http.StatusMethodNotAllowed, "METHOD_NOT_ALLOWED", "method is not allowed") return } w.Header().Set("Content-Type", "text/html; charset=utf-8") _, _ = io.WriteString(w, walletHTML) } func (s *appServer) handleSession(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { writeError(w, http.StatusMethodNotAllowed, "METHOD_NOT_ALLOWED", "method is not allowed") return } raw := firstNonEmpty(r.Header.Get("X-Telegram-Init-Data"), r.URL.Query().Get("init_data")) session, err := parseWebAppSession(raw, s.cfg.token, s.cfg.initMax) if err != nil { writeError(w, http.StatusUnauthorized, "INIT_DATA_INVALID", err.Error()) return } snapshot := s.wallet.snapshot(session.User.ID) writeJSON(w, http.StatusOK, map[string]any{ "ok": true, "session": session, "wallet": snapshot, "bot_api": s.botapi.enabled(), "demo_url": s.cfg.publicURL, }) } func (s *appServer) handleTransfer(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { writeError(w, http.StatusMethodNotAllowed, "METHOD_NOT_ALLOWED", "method is not allowed") return } var payload transferRequest if err := decodeJSON(r, &payload); err != nil { writeError(w, http.StatusBadRequest, "BAD_REQUEST", "invalid JSON") return } session, err := parseWebAppSession(payload.InitData, s.cfg.token, s.cfg.initMax) if err != nil { writeError(w, http.StatusUnauthorized, "INIT_DATA_INVALID", err.Error()) return } tx, snapshot, err := s.wallet.transfer(session.User.ID, payload.To, payload.AmountCents, payload.Memo) if err != nil { writeError(w, http.StatusBadRequest, "TRANSFER_INVALID", err.Error()) return } writeJSON(w, http.StatusOK, map[string]any{"ok": true, "session": session, "tx": tx, "wallet": snapshot}) } func (s *appServer) handleSharePrepared(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { writeError(w, http.StatusMethodNotAllowed, "METHOD_NOT_ALLOWED", "method is not allowed") return } if !s.botapi.enabled() { writeError(w, http.StatusServiceUnavailable, "BOT_API_DISABLED", "bot token is required") return } var payload shareRequest if err := decodeJSON(r, &payload); err != nil { writeError(w, http.StatusBadRequest, "BAD_REQUEST", "invalid JSON") return } session, err := parseWebAppSession(payload.InitData, s.cfg.token, s.cfg.initMax) if err != nil { writeError(w, http.StatusUnauthorized, "INIT_DATA_INVALID", err.Error()) return } result := walletInlineResult("wallet-share-"+strconv.FormatInt(time.Now().UnixNano(), 36), "Wallet receipt", receiptText(session, payload), s.publicResultURL()) ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second) defer cancel() prepared, err := s.botapi.savePreparedInlineMessage(ctx, session.User.ID, result) if err != nil { writeError(w, http.StatusBadGateway, "BOT_API_ERROR", err.Error()) return } writeJSON(w, http.StatusOK, map[string]any{"ok": true, "prepared": prepared}) } func (s *appServer) handleAnswerWebAppQuery(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { writeError(w, http.StatusMethodNotAllowed, "METHOD_NOT_ALLOWED", "method is not allowed") return } if !s.botapi.enabled() { writeError(w, http.StatusServiceUnavailable, "BOT_API_DISABLED", "bot token is required") return } var payload shareRequest if err := decodeJSON(r, &payload); err != nil { writeError(w, http.StatusBadRequest, "BAD_REQUEST", "invalid JSON") return } session, err := parseWebAppSession(payload.InitData, s.cfg.token, s.cfg.initMax) if err != nil { writeError(w, http.StatusUnauthorized, "INIT_DATA_INVALID", err.Error()) return } if session.QueryID == "" { writeError(w, http.StatusBadRequest, "QUERY_ID_INVALID", "current launch has no web_app query_id") return } result := walletInlineResult("wallet-answer-"+strconv.FormatInt(time.Now().UnixNano(), 36), "Wallet receipt", receiptText(session, payload), s.publicResultURL()) ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second) defer cancel() out, err := s.botapi.answerWebAppQuery(ctx, session.QueryID, result) if err != nil { writeError(w, http.StatusBadGateway, "BOT_API_ERROR", err.Error()) return } writeJSON(w, http.StatusOK, map[string]any{"ok": true, "sent": out}) } func (s *appServer) handlePaymentIntent(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { writeError(w, http.StatusMethodNotAllowed, "METHOD_NOT_ALLOWED", "method is not allowed") return } writeError(w, http.StatusNotImplemented, "PAYMENTS_BLOCKED", "durable invoice/payment state is not implemented in telesrv yet") } func (s *appServer) publicResultURL() string { if isHTTPSURL(s.cfg.publicURL) { return s.cfg.publicURL } return "" } type transferRequest struct { InitData string `json:"init_data"` To string `json:"to"` AmountCents int64 `json:"amount_cents"` Memo string `json:"memo"` } type shareRequest struct { InitData string `json:"init_data"` AmountCents int64 `json:"amount_cents"` Memo string `json:"memo"` } type webAppSession struct { Demo bool `json:"demo"` Verified bool `json:"verified"` QueryID string `json:"query_id,omitempty"` StartParam string `json:"start_param,omitempty"` AuthDate int64 `json:"auth_date,omitempty"` User webAppUser `json:"user"` } type webAppUser struct { ID int64 `json:"id"` FirstName string `json:"first_name"` LastName string `json:"last_name,omitempty"` Username string `json:"username,omitempty"` } func parseWebAppSession(raw, botToken string, maxAge time.Duration) (webAppSession, error) { raw = strings.TrimSpace(raw) if raw == "" { return webAppSession{ Demo: true, User: webAppUser{ID: 0, FirstName: "Browser", Username: "preview"}, }, nil } values, err := url.ParseQuery(raw) if err != nil { return webAppSession{}, err } if strings.TrimSpace(botToken) != "" { hash := values.Get("hash") if hash == "" || !validWebAppHash(values, botToken) { return webAppSession{}, errors.New("hash mismatch") } } var authDate int64 if rawDate := values.Get("auth_date"); rawDate != "" { authDate, err = strconv.ParseInt(rawDate, 10, 64) if err != nil || authDate <= 0 { return webAppSession{}, errors.New("auth_date invalid") } if maxAge > 0 && time.Since(time.Unix(authDate, 0)) > maxAge { return webAppSession{}, errors.New("auth_date expired") } } var user webAppUser if rawUser := values.Get("user"); rawUser != "" { if err := json.Unmarshal([]byte(rawUser), &user); err != nil { return webAppSession{}, errors.New("user invalid") } } if user.ID <= 0 { return webAppSession{}, errors.New("user missing") } return webAppSession{ Verified: strings.TrimSpace(botToken) != "", QueryID: values.Get("query_id"), StartParam: values.Get("start_param"), AuthDate: authDate, User: user, }, nil } func validWebAppHash(values url.Values, botToken string) bool { got := values.Get("hash") if got == "" { return false } want := webAppInitDataHash(values, botToken) return hmac.Equal([]byte(got), []byte(want)) } func webAppInitDataHash(values url.Values, botToken string) string { check := webAppDataCheckString(values) secretMAC := hmac.New(sha256.New, []byte("WebAppData")) _, _ = secretMAC.Write([]byte(botToken)) secret := secretMAC.Sum(nil) dataMAC := hmac.New(sha256.New, secret) _, _ = dataMAC.Write([]byte(check)) return hex.EncodeToString(dataMAC.Sum(nil)) } func webAppDataCheckString(values url.Values) string { keys := make([]string, 0, len(values)) for key := range values { if key != "hash" { keys = append(keys, key) } } sort.Strings(keys) var b strings.Builder for i, key := range keys { if i > 0 { b.WriteByte('\n') } b.WriteString(key) b.WriteByte('=') b.WriteString(values.Get(key)) } return b.String() } type walletStore struct { mu sync.Mutex balances map[int64]int64 txs map[int64][]walletTx nextID int64 } type walletSnapshot struct { BalanceCents int64 `json:"balance_cents"` Currency string `json:"currency"` Recent []walletTx `json:"recent"` } type walletTx struct { ID int64 `json:"id"` To string `json:"to"` AmountCents int64 `json:"amount_cents"` Memo string `json:"memo"` CreatedAt int64 `json:"created_at"` } func newWalletStore() *walletStore { return &walletStore{ balances: map[int64]int64{}, txs: map[int64][]walletTx{}, } } func (s *walletStore) snapshot(userID int64) walletSnapshot { s.mu.Lock() defer s.mu.Unlock() s.ensure(userID) return s.snapshotLocked(userID) } func (s *walletStore) transfer(userID int64, to string, amount int64, memo string) (walletTx, walletSnapshot, error) { to = strings.TrimSpace(to) memo = strings.TrimSpace(memo) if to == "" { return walletTx{}, walletSnapshot{}, errors.New("recipient is required") } if amount <= 0 || amount > maxTransferCents { return walletTx{}, walletSnapshot{}, errors.New("amount is out of demo bounds") } if len(memo) > maxMemoLen { return walletTx{}, walletSnapshot{}, errors.New("memo is too long") } s.mu.Lock() defer s.mu.Unlock() s.ensure(userID) if s.balances[userID] < amount { return walletTx{}, walletSnapshot{}, errors.New("insufficient balance") } s.nextID++ tx := walletTx{ID: s.nextID, To: to, AmountCents: amount, Memo: memo, CreatedAt: time.Now().Unix()} s.balances[userID] -= amount s.txs[userID] = append([]walletTx{tx}, s.txs[userID]...) if len(s.txs[userID]) > 8 { s.txs[userID] = s.txs[userID][:8] } return tx, s.snapshotLocked(userID), nil } func (s *walletStore) ensure(userID int64) { if _, ok := s.balances[userID]; !ok { s.balances[userID] = defaultBalanceCents } } func (s *walletStore) snapshotLocked(userID int64) walletSnapshot { recent := append([]walletTx(nil), s.txs[userID]...) return walletSnapshot{BalanceCents: s.balances[userID], Currency: "TSC", Recent: recent} } type botAPIClient struct { base string token string http *http.Client } func newBotAPIClient(base, token string) *botAPIClient { return &botAPIClient{ base: strings.TrimRight(base, "/"), token: strings.TrimSpace(token), http: &http.Client{Timeout: 8 * time.Second}, } } func (c *botAPIClient) enabled() bool { return c != nil && c.base != "" && c.token != "" } func (c *botAPIClient) setChatMenuButton(ctx context.Context, text, webURL string) error { payload := map[string]any{ "menu_button": map[string]any{ "type": "web_app", "text": text, "web_app": map[string]any{ "url": webURL, }, }, } var out botAPIResponse[bool] return c.post(ctx, "setChatMenuButton", payload, &out) } func (c *botAPIClient) answerWebAppQuery(ctx context.Context, queryID string, result map[string]any) (map[string]any, error) { payload := map[string]any{"web_app_query_id": queryID, "result": result} var out botAPIResponse[map[string]any] if err := c.post(ctx, "answerWebAppQuery", payload, &out); err != nil { return nil, err } return out.Result, nil } func (c *botAPIClient) savePreparedInlineMessage(ctx context.Context, userID int64, result map[string]any) (map[string]any, error) { payload := map[string]any{ "user_id": userID, "result": result, "allow_user_chats": true, "allow_group_chats": true, "allow_channel_chats": true, } var out botAPIResponse[map[string]any] if err := c.post(ctx, "savePreparedInlineMessage", payload, &out); err != nil { return nil, err } return out.Result, nil } func (c *botAPIClient) post(ctx context.Context, method string, payload any, out any) error { if !c.enabled() { return errors.New("bot api disabled") } body, err := json.Marshal(payload) if err != nil { return err } req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.base+"/bot"+url.PathEscape(c.token)+"/"+method, bytes.NewReader(body)) if err != nil { return err } req.Header.Set("Content-Type", "application/json") resp, err := c.http.Do(req) if err != nil { return err } defer resp.Body.Close() limited := io.LimitReader(resp.Body, 1<<20) if err := json.NewDecoder(limited).Decode(out); err != nil { return err } if resp.StatusCode < 200 || resp.StatusCode >= 300 { return fmt.Errorf("%s failed with HTTP %d", method, resp.StatusCode) } return nil } type botAPIResponse[T any] struct { OK bool `json:"ok"` Description string `json:"description"` Result T `json:"result"` } func walletInlineResult(id, title, message, link string) map[string]any { result := map[string]any{ "type": "article", "id": id, "title": title, "description": "Telesrv wallet demo receipt", "input_message_content": map[string]any{ "message_text": message, "disable_web_page_preview": true, }, } if link != "" { result["url"] = link result["reply_markup"] = map[string]any{ "inline_keyboard": [][]map[string]any{{ {"text": "Open Wallet", "url": link}, }}, } } return result } func receiptText(session webAppSession, payload shareRequest) string { name := session.User.FirstName if name == "" { name = "Wallet user" } memo := strings.TrimSpace(payload.Memo) if memo == "" { memo = "wallet demo transfer" } return fmt.Sprintf("%s shared a wallet receipt: %s TSC - %s", name, formatCents(payload.AmountCents), memo) } func decodeJSON(r *http.Request, out any) error { defer r.Body.Close() return json.NewDecoder(io.LimitReader(r.Body, 1<<20)).Decode(out) } func writeJSON(w http.ResponseWriter, status int, body any) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(status) _ = json.NewEncoder(w).Encode(body) } func writeError(w http.ResponseWriter, status int, code, message string) { writeJSON(w, status, map[string]any{"ok": false, "error": code, "message": message}) } func isHTTPSURL(raw string) bool { parsed, err := url.Parse(raw) return err == nil && parsed.Scheme == "https" && parsed.Host != "" } func firstNonEmpty(values ...string) string { for _, value := range values { if strings.TrimSpace(value) != "" { return value } } return "" } func envOr(key, fallback string) string { if value := strings.TrimSpace(os.Getenv(key)); value != "" { return value } return fallback } func formatCents(cents int64) string { sign := "" if cents < 0 { sign = "-" cents = -cents } return fmt.Sprintf("%s%d.%02d", sign, cents/100, cents%100) } const walletHTML = ` Telesrv Wallet

Telesrv Wallet

Loading session...
Mini App
0.00 TSC

Transfer

Recent activity

    `