// 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 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 a single bot. ValidateInitData/ValidateLoginWidget verify // launch data against its token; Notify renders the message in the recipient's // interface language (the single bot needs no routing); the admin methods deliver // through that bot. package connector import ( "context" "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 the configured bot: its sender, game channel id, and the two HMAC // validators bound to its token. type BotRuntime struct { // Sender delivers messages through the bot. Sender Sender // ChannelID is the bot's game channel (0 disables channel posts). ChannelID int64 // InitValidator verifies Mini App initData signed by the bot's token. InitValidator initdata.Validator // WidgetValidator verifies Login Widget data signed by the bot's token. WidgetValidator loginwidget.Validator } // Server implements telegramv1.TelegramServer over the single configured bot. type Server struct { telegramv1.UnimplementedTelegramServer bot BotRuntime log *zap.Logger } // NewServer builds the gRPC service from the configured bot. func NewServer(bot BotRuntime, log *zap.Logger) *Server { if log == nil { log = zap.NewNop() } return &Server{bot: bot, log: log} } // ValidateInitData verifies Mini App launch data against the bot's token and returns // the user identity. func (s *Server) ValidateInitData(ctx context.Context, req *telegramv1.ValidateInitDataRequest) (*telegramv1.ValidateInitDataResponse, error) { u, err := s.bot.InitValidator.Validate(req.GetInitData()) if err != nil { return nil, status.Error(codes.InvalidArgument, err.Error()) } return &telegramv1.ValidateInitDataResponse{ ExternalId: u.ExternalID, Username: u.Username, FirstName: u.FirstName, LanguageCode: u.LanguageCode, }, nil } // ValidateLoginWidget verifies Login Widget authorization data against the bot's // token and returns the user identity, for attaching a Telegram identity to an // existing account. func (s *Server) ValidateLoginWidget(ctx context.Context, req *telegramv1.ValidateLoginWidgetRequest) (*telegramv1.ValidateLoginWidgetResponse, error) { u, err := s.bot.WidgetValidator.Validate(req.GetData()) if err != nil { return nil, status.Error(codes.InvalidArgument, err.Error()) } return &telegramv1.ValidateLoginWidgetResponse{ ExternalId: u.ExternalID, Username: u.Username, FirstName: u.FirstName, }, nil } // Notify renders and delivers an out-of-app notification through the bot. The message // is rendered in the recipient's interface language (req language). It reports // delivered=false (without an error) when 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) { 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 := s.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. func (s *Server) SendToUser(ctx context.Context, req *telegramv1.SendToUserRequest) (*telegramv1.SendResponse, error) { chat, err := parseChatID(req.GetExternalId()) if err != nil { return nil, status.Error(codes.InvalidArgument, err.Error()) } if err := s.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 bot's game channel. func (s *Server) SendToGameChannel(ctx context.Context, req *telegramv1.SendToGameChannelRequest) (*telegramv1.SendResponse, error) { if s.bot.ChannelID == 0 { return nil, status.Error(codes.FailedPrecondition, "game channel is not configured") } if err := s.bot.Sender.SendText(ctx, s.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 } // 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 }