// Package connector implements the Telegram gRPC service (pkg/proto/telegram/v1): // the gateway calls ValidateInitData (Mini App auth) and Notify (out-of-app push); // the admin surface (Stage 10) calls SendToUser and SendToGameChannel. The generic // methods address a recipient by the identity external_id, so a future platform // connector can implement the same service. // // The connector hosts one bot per configured service language (en/ru); the same // Telegram user id spans them all. ValidateInitData tries each bot's token in turn // and reports which bot validated (its service language); the push and admin // methods route to the bot the request selects by language. package connector import ( "context" "errors" "fmt" "strconv" "go.uber.org/zap" "google.golang.org/grpc/codes" "google.golang.org/grpc/status" telegramv1 "scrabble/pkg/proto/telegram/v1" "scrabble/platform/telegram/internal/initdata" "scrabble/platform/telegram/internal/loginwidget" "scrabble/platform/telegram/internal/render" ) // Sender delivers Telegram messages to a chat. *bot.Bot implements it. type Sender interface { // Notify sends a notification with a Mini App launch button to chatID. Notify(ctx context.Context, chatID int64, text, buttonText, startParam string) error // SendText sends a plain text message to chatID. SendText(ctx context.Context, chatID int64, text string) error } // BotRuntime is one configured language-tagged bot: its sender, game channel id, // and the two HMAC validators bound to its token. type BotRuntime struct { // Language is the bot's service language (en/ru). Language string // Sender delivers messages through this bot. Sender Sender // ChannelID is this bot's game channel (0 disables channel posts). ChannelID int64 // InitValidator verifies Mini App initData signed by this bot's token. InitValidator initdata.Validator // WidgetValidator verifies Login Widget data signed by this bot's token. WidgetValidator loginwidget.Validator } // Server implements telegramv1.TelegramServer over one or more language-tagged bots. type Server struct { telegramv1.UnimplementedTelegramServer bots map[string]BotRuntime // keyed by service language order []string // stable iteration order for validation log *zap.Logger } // NewServer builds the gRPC service from the configured bots (at least one). func NewServer(bots []BotRuntime, log *zap.Logger) *Server { if log == nil { log = zap.NewNop() } m := make(map[string]BotRuntime, len(bots)) order := make([]string, 0, len(bots)) for _, b := range bots { m[b.Language] = b order = append(order, b.Language) } return &Server{bots: m, order: order, log: log} } // ValidateInitData verifies Mini App launch data against each bot's token in turn // and returns the user identity plus the validating bot's service language (which // routes the user's later push) and its set of offered game languages. func (s *Server) ValidateInitData(ctx context.Context, req *telegramv1.ValidateInitDataRequest) (*telegramv1.ValidateInitDataResponse, error) { var lastErr error for _, lang := range s.order { u, err := s.bots[lang].InitValidator.Validate(req.GetInitData()) if err != nil { lastErr = err continue } return &telegramv1.ValidateInitDataResponse{ ExternalId: u.ExternalID, Username: u.Username, FirstName: u.FirstName, LanguageCode: u.LanguageCode, ServiceLanguage: lang, SupportedLanguages: []string{lang}, }, nil } if lastErr == nil { lastErr = errors.New("no bot configured") } return nil, status.Error(codes.InvalidArgument, lastErr.Error()) } // ValidateLoginWidget verifies Login Widget authorization data against each bot's // token in turn and returns the user identity, for attaching a Telegram identity to // an existing account (Stage 11). func (s *Server) ValidateLoginWidget(ctx context.Context, req *telegramv1.ValidateLoginWidgetRequest) (*telegramv1.ValidateLoginWidgetResponse, error) { var lastErr error for _, lang := range s.order { u, err := s.bots[lang].WidgetValidator.Validate(req.GetData()) if err != nil { lastErr = err continue } return &telegramv1.ValidateLoginWidgetResponse{ ExternalId: u.ExternalID, Username: u.Username, FirstName: u.FirstName, }, nil } if lastErr == nil { lastErr = errors.New("no bot configured") } return nil, status.Error(codes.InvalidArgument, lastErr.Error()) } // Notify renders and delivers an out-of-app notification through the bot selected by // the recipient's service language. It reports delivered=false (without an error) // when no bot serves that language, the kind is not pushed out-of-app, or the bot // could not deliver (e.g. the user never started it), so the gateway treats a // fallback miss as best-effort. func (s *Server) Notify(ctx context.Context, req *telegramv1.NotifyRequest) (*telegramv1.NotifyResponse, error) { bot, ok := s.bots[req.GetLanguage()] if !ok { s.log.Warn("notify: no bot for language", zap.String("language", req.GetLanguage()), zap.String("kind", req.GetKind())) return &telegramv1.NotifyResponse{Delivered: false}, nil } msg, ok := render.Render(req.GetKind(), req.GetPayload(), req.GetLanguage()) if !ok { return &telegramv1.NotifyResponse{Delivered: false}, nil } chat, err := parseChatID(req.GetExternalId()) if err != nil { return nil, status.Error(codes.InvalidArgument, err.Error()) } if err := bot.Sender.Notify(ctx, chat, msg.Text, msg.ButtonText, msg.StartParam); err != nil { s.log.Warn("notify delivery failed", zap.String("kind", req.GetKind()), zap.Error(err)) return &telegramv1.NotifyResponse{Delivered: false}, nil } return &telegramv1.NotifyResponse{Delivered: true}, nil } // SendToUser sends an arbitrary admin message to one user through the bot selected // by language (an operator choice in the admin console). func (s *Server) SendToUser(ctx context.Context, req *telegramv1.SendToUserRequest) (*telegramv1.SendResponse, error) { bot, err := s.botFor(req.GetLanguage()) if err != nil { return nil, err } chat, err := parseChatID(req.GetExternalId()) if err != nil { return nil, status.Error(codes.InvalidArgument, err.Error()) } if err := bot.Sender.SendText(ctx, chat, req.GetText()); err != nil { s.log.Warn("send to user failed", zap.Error(err)) return &telegramv1.SendResponse{Delivered: false}, nil } return &telegramv1.SendResponse{Delivered: true}, nil } // SendToGameChannel posts an arbitrary admin message to the game channel of the bot // selected by language (an operator choice in the admin console). func (s *Server) SendToGameChannel(ctx context.Context, req *telegramv1.SendToGameChannelRequest) (*telegramv1.SendResponse, error) { bot, err := s.botFor(req.GetLanguage()) if err != nil { return nil, err } if bot.ChannelID == 0 { return nil, status.Error(codes.FailedPrecondition, fmt.Sprintf("game channel is not configured for language %q", req.GetLanguage())) } if err := bot.Sender.SendText(ctx, bot.ChannelID, req.GetText()); err != nil { s.log.Warn("send to channel failed", zap.Error(err)) return &telegramv1.SendResponse{Delivered: false}, nil } return &telegramv1.SendResponse{Delivered: true}, nil } // botFor returns the bot tagged with language, or a FailedPrecondition error when no // bot serves it (admin broadcasts choose the language explicitly). func (s *Server) botFor(language string) (BotRuntime, error) { bot, ok := s.bots[language] if !ok { return BotRuntime{}, status.Error(codes.FailedPrecondition, fmt.Sprintf("no bot configured for language %q", language)) } return bot, nil } // parseChatID converts a Telegram identity external_id into a numeric chat id. func parseChatID(externalID string) (int64, error) { id, err := strconv.ParseInt(externalID, 10, 64) if err != nil { return 0, fmt.Errorf("invalid external_id %q", externalID) } return id, nil }