Stage 9: Telegram integration (connector side-service, Mini App, out-of-app push)
Tests · Go / test (push) Successful in 7s
Tests · Integration / integration (push) Successful in 12s
Tests · Go / test (pull_request) Successful in 6s
Tests · Integration / integration (pull_request) Successful in 11s
Tests · UI / test (pull_request) Successful in 19s
Tests · Go / test (push) Successful in 7s
Tests · Integration / integration (push) Successful in 12s
Tests · Go / test (pull_request) Successful in 6s
Tests · Integration / integration (pull_request) Successful in 11s
Tests · UI / test (pull_request) Successful in 19s
New platform/telegram connector (own container, bot token only there): - go-telegram/bot long-poll loop: /start deep-links + Mini App launch button. - gRPC API pkg/proto/telegram/v1 (Telegram service): ValidateInitData, Notify (renders a localized message + deep-link button), SendToUser/SendToGameChannel (admin, wired in Stage 10). Generic methods are platform-agnostic (external_id). - Bot API base override for Telegram's test environment; Dockerfile + compose (VPN sidecar, no public ingress); README. Gateway: - initData validation relocated from the gateway into the connector; the gateway calls ValidateInitData over gRPC (GATEWAY_CONNECTOR_ADDR), drops the bot token, and deletes internal/auth. - Out-of-app push: runPushPump routes events whose recipient has no live in-app stream to connector.Notify, gated by /internal/push-target + the in-app-only flag (race-free de-dup); HasSubscribers added to the push hub. Backend: - Migration 00007 accounts.notifications_in_app_only (default true) + jetgen. - ProvisionTelegram seeds a new account's language/display name from the launch fields; IdentityExternalID reverse lookup; /internal/push-target handler. UI: - Telegram Mini App launch: detect initData, apply themeParams, authTelegram, route the deep-link start_param (g/i/f); /telegram/ guard redirects outside Telegram. Vite relative base + telegram-web-app.js. In-app-only profile toggle; share-to-Telegram link for a friend code. Vitest + Playwright coverage. Wire/docs/CI: fbs Profile/UpdateProfileRequest gain notifications_in_app_only (Go + TS); go.work uses ./platform/telegram; go-unit.yaml covers it; PLAN, ARCHITECTURE, FUNCTIONAL (+ru), UI_DESIGN, READMEs updated.
This commit is contained in:
@@ -0,0 +1,155 @@
|
||||
// Package bot wraps the Telegram Bot API client (github.com/go-telegram/bot): it
|
||||
// runs the long-poll update loop — replying to /start (with an optional deep-link
|
||||
// payload) and any other message with a Mini App launch button — and sends the
|
||||
// notification and admin messages the connector requests. The bot token lives only
|
||||
// in this process.
|
||||
package bot
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
tgbot "github.com/go-telegram/bot"
|
||||
"github.com/go-telegram/bot/models"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// Config configures the bot wrapper.
|
||||
type Config struct {
|
||||
// Token is the Bot API token.
|
||||
Token string
|
||||
// APIBaseURL overrides the Bot API host ("" uses https://api.telegram.org).
|
||||
APIBaseURL string
|
||||
// TestEnv routes requests to the Bot API test environment.
|
||||
TestEnv bool
|
||||
// MiniAppURL is the base URL of the Mini App launch button.
|
||||
MiniAppURL string
|
||||
}
|
||||
|
||||
// Bot wraps a Telegram Bot API client and the Mini App launch URL.
|
||||
type Bot struct {
|
||||
api *tgbot.Bot
|
||||
miniAppURL string
|
||||
log *zap.Logger
|
||||
}
|
||||
|
||||
// New builds the bot wrapper, registering the /start handler and a default handler
|
||||
// that both reply with a Mini App launch button. It does not start polling; call
|
||||
// Run for that.
|
||||
func New(cfg Config, log *zap.Logger) (*Bot, error) {
|
||||
if log == nil {
|
||||
log = zap.NewNop()
|
||||
}
|
||||
t := &Bot{miniAppURL: cfg.MiniAppURL, log: log}
|
||||
|
||||
token := cfg.Token
|
||||
if cfg.TestEnv {
|
||||
// The Bot API test environment lives under /bot<token>/test/METHOD; the
|
||||
// client builds <host>/bot<token>/<method>, so suffixing the token with
|
||||
// "/test" injects the test segment without a custom host.
|
||||
token += "/test"
|
||||
}
|
||||
opts := []tgbot.Option{
|
||||
tgbot.WithDefaultHandler(t.handleStart),
|
||||
tgbot.WithMessageTextHandler("/start", tgbot.MatchTypePrefix, t.handleStart),
|
||||
}
|
||||
if cfg.APIBaseURL != "" {
|
||||
opts = append(opts, tgbot.WithServerURL(cfg.APIBaseURL))
|
||||
}
|
||||
api, err := tgbot.New(token, opts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
t.api = api
|
||||
return t, nil
|
||||
}
|
||||
|
||||
// Run sets the bot commands and the Mini App menu button, then blocks on the
|
||||
// long-poll update loop until ctx is cancelled.
|
||||
func (t *Bot) Run(ctx context.Context) {
|
||||
if _, err := t.api.SetMyCommands(ctx, &tgbot.SetMyCommandsParams{
|
||||
Commands: []models.BotCommand{{Command: "start", Description: "Open Scrabble"}},
|
||||
}); err != nil {
|
||||
t.log.Warn("set commands failed", zap.Error(err))
|
||||
}
|
||||
if _, err := t.api.SetChatMenuButton(ctx, &tgbot.SetChatMenuButtonParams{
|
||||
MenuButton: models.MenuButtonWebApp{
|
||||
Type: models.MenuButtonTypeWebApp,
|
||||
Text: "Play",
|
||||
WebApp: models.WebAppInfo{URL: t.miniAppURL},
|
||||
},
|
||||
}); err != nil {
|
||||
t.log.Warn("set menu button failed", zap.Error(err))
|
||||
}
|
||||
t.api.Start(ctx)
|
||||
}
|
||||
|
||||
// Notify sends a notification message with a Mini App launch button that opens the
|
||||
// app at startParam (empty opens the lobby).
|
||||
func (t *Bot) Notify(ctx context.Context, chatID int64, text, buttonText, startParam string) error {
|
||||
_, err := t.api.SendMessage(ctx, &tgbot.SendMessageParams{
|
||||
ChatID: chatID,
|
||||
Text: text,
|
||||
ReplyMarkup: t.launchMarkup(buttonText, startParam),
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
// SendText sends a plain text message with no markup (admin use).
|
||||
func (t *Bot) SendText(ctx context.Context, chatID int64, text string) error {
|
||||
_, err := t.api.SendMessage(ctx, &tgbot.SendMessageParams{ChatID: chatID, Text: text})
|
||||
return err
|
||||
}
|
||||
|
||||
// handleStart replies to /start (with an optional deep-link payload) and to any
|
||||
// other message with a Mini App launch button.
|
||||
func (t *Bot) handleStart(ctx context.Context, api *tgbot.Bot, update *models.Update) {
|
||||
if update.Message == nil {
|
||||
return
|
||||
}
|
||||
startParam := startPayload(update.Message.Text)
|
||||
if _, err := api.SendMessage(ctx, &tgbot.SendMessageParams{
|
||||
ChatID: update.Message.Chat.ID,
|
||||
Text: "Tap to open Scrabble.",
|
||||
ReplyMarkup: t.launchMarkup("Open Scrabble", startParam),
|
||||
}); err != nil {
|
||||
t.log.Warn("reply to start failed", zap.Error(err))
|
||||
}
|
||||
}
|
||||
|
||||
// launchMarkup builds the single-button inline keyboard that opens the Mini App at
|
||||
// startParam.
|
||||
func (t *Bot) launchMarkup(buttonText, startParam string) *models.InlineKeyboardMarkup {
|
||||
return &models.InlineKeyboardMarkup{
|
||||
InlineKeyboard: [][]models.InlineKeyboardButton{{
|
||||
{Text: buttonText, WebApp: &models.WebAppInfo{URL: t.launchURL(startParam)}},
|
||||
}},
|
||||
}
|
||||
}
|
||||
|
||||
// launchURL appends the deep-link start parameter to the Mini App URL as a startapp
|
||||
// query parameter; an empty parameter returns the base URL unchanged.
|
||||
func (t *Bot) launchURL(startParam string) string {
|
||||
if startParam == "" {
|
||||
return t.miniAppURL
|
||||
}
|
||||
u, err := url.Parse(t.miniAppURL)
|
||||
if err != nil {
|
||||
return t.miniAppURL
|
||||
}
|
||||
q := u.Query()
|
||||
q.Set("startapp", startParam)
|
||||
u.RawQuery = q.Encode()
|
||||
return u.String()
|
||||
}
|
||||
|
||||
// startPayload extracts the deep-link payload from a "/start <payload>" command;
|
||||
// any other text yields an empty payload (open the lobby).
|
||||
func startPayload(text string) string {
|
||||
const cmd = "/start"
|
||||
if !strings.HasPrefix(text, cmd) {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(strings.TrimPrefix(text, cmd))
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
package bot
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// fakeBotAPI answers getMe (so bot.New succeeds offline) and records the last
|
||||
// sendMessage form fields.
|
||||
type fakeBotAPI struct {
|
||||
chatID string
|
||||
text string
|
||||
replyMarkup string
|
||||
}
|
||||
|
||||
func (f *fakeBotAPI) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
case strings.HasSuffix(r.URL.Path, "/getMe"):
|
||||
io.WriteString(w, `{"ok":true,"result":{"id":1,"is_bot":true,"first_name":"test","username":"testbot"}}`)
|
||||
case strings.HasSuffix(r.URL.Path, "/sendMessage"):
|
||||
f.chatID = r.FormValue("chat_id")
|
||||
f.text = r.FormValue("text")
|
||||
f.replyMarkup = r.FormValue("reply_markup")
|
||||
io.WriteString(w, `{"ok":true,"result":{"message_id":1}}`)
|
||||
default:
|
||||
io.WriteString(w, `{"ok":true,"result":true}`)
|
||||
}
|
||||
}
|
||||
|
||||
func newTestBot(t *testing.T, api http.Handler) *Bot {
|
||||
t.Helper()
|
||||
srv := httptest.NewServer(api)
|
||||
t.Cleanup(srv.Close)
|
||||
b, err := New(Config{Token: "123:ABC", APIBaseURL: srv.URL, MiniAppURL: "https://example.com/telegram/"}, zap.NewNop())
|
||||
if err != nil {
|
||||
t.Fatalf("new bot: %v", err)
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
func TestNotifyBuildsLaunchButton(t *testing.T) {
|
||||
api := &fakeBotAPI{}
|
||||
b := newTestBot(t, api)
|
||||
if err := b.Notify(context.Background(), 12345, "It's your turn.", "Open game", "g7c9e"); err != nil {
|
||||
t.Fatalf("notify: %v", err)
|
||||
}
|
||||
if api.chatID != "12345" {
|
||||
t.Errorf("chat_id = %q, want 12345", api.chatID)
|
||||
}
|
||||
if api.text != "It's your turn." {
|
||||
t.Errorf("text = %q", api.text)
|
||||
}
|
||||
if !strings.Contains(api.replyMarkup, "web_app") || !strings.Contains(api.replyMarkup, "startapp=g7c9e") {
|
||||
t.Errorf("reply_markup = %q, want a web_app button with startapp=g7c9e", api.replyMarkup)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSendTextHasNoMarkup(t *testing.T) {
|
||||
api := &fakeBotAPI{}
|
||||
b := newTestBot(t, api)
|
||||
if err := b.SendText(context.Background(), 999, "plain"); err != nil {
|
||||
t.Fatalf("send text: %v", err)
|
||||
}
|
||||
if api.chatID != "999" || api.text != "plain" {
|
||||
t.Errorf("chat_id=%q text=%q, want 999/plain", api.chatID, api.text)
|
||||
}
|
||||
if api.replyMarkup != "" {
|
||||
t.Errorf("reply_markup = %q, want empty", api.replyMarkup)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStartPayload(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"/start g123": "g123",
|
||||
"/start": "",
|
||||
"/start f99 ": "f99",
|
||||
"hello": "",
|
||||
}
|
||||
for in, want := range cases {
|
||||
if got := startPayload(in); got != want {
|
||||
t.Errorf("startPayload(%q) = %q, want %q", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLaunchURL(t *testing.T) {
|
||||
b := &Bot{miniAppURL: "https://example.com/telegram/"}
|
||||
if got := b.launchURL(""); got != "https://example.com/telegram/" {
|
||||
t.Errorf("empty start param = %q, want the base URL", got)
|
||||
}
|
||||
if got := b.launchURL("g123"); !strings.Contains(got, "startapp=g123") {
|
||||
t.Errorf("launchURL = %q, want startapp=g123", got)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user