// 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) will call SendToUser and SendToGameChannel. The // generic methods address a recipient by the identity external_id, so a future // platform connector can implement the same service. 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/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 } // Server implements telegramv1.TelegramServer. type Server struct { telegramv1.UnimplementedTelegramServer validator initdata.Validator sender Sender channelID int64 log *zap.Logger } // NewServer builds the gRPC service from a validator (for ValidateInitData), a // sender (the bot), and the configured game channel id (0 disables channel posts). func NewServer(validator initdata.Validator, sender Sender, channelID int64, log *zap.Logger) *Server { if log == nil { log = zap.NewNop() } return &Server{validator: validator, sender: sender, channelID: channelID, log: log} } // ValidateInitData verifies Mini App launch data and returns the user identity. func (s *Server) ValidateInitData(ctx context.Context, req *telegramv1.ValidateInitDataRequest) (*telegramv1.ValidateInitDataResponse, error) { u, err := s.validator.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 } // Notify renders and delivers an out-of-app notification. It reports // delivered=false (without an error) for a kind that is not pushed out-of-app or a // delivery the bot could not complete (e.g. the user never started the bot), 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.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. 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.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 admin message to the configured game channel. func (s *Server) SendToGameChannel(ctx context.Context, req *telegramv1.SendToGameChannelRequest) (*telegramv1.SendResponse, error) { if s.channelID == 0 { return nil, status.Error(codes.FailedPrecondition, "game channel is not configured") } if err := s.sender.SendText(ctx, s.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 }