diff --git a/Makefile b/Makefile index 3b7c42d..81082f9 100644 --- a/Makefile +++ b/Makefile @@ -9,6 +9,8 @@ fclean: @rm $(APP_NAME) @echo "Removed App" +re: fclean all + tests: @go test -v ./... diff --git a/cmd/application/main.go b/cmd/application/main.go index c5f9c25..43f45c3 100644 --- a/cmd/application/main.go +++ b/cmd/application/main.go @@ -1,198 +1,37 @@ package main import ( - "encoding/json" "fmt" - "net/http" "os" "os/signal" - "regexp" - "strings" - "sync" "syscall" - "time" + "github.com/0bvim/goctobot/internal/app/model" "github.com/0bvim/goctobot/utils" ) -var personalGithubToken string - -type User struct { - Login string `json:"login"` -} - func init() { - // Check if the token is set - personalGithubToken = utils.GetToken() - // check args if len(os.Args) == 1 { utils.PrintHelp() os.Exit(1) } -} - -func handleRateLimit(count *int) { - *count++ - delay := time.Duration(60*(*count)) * time.Second - fmt.Printf("Rate limit exceeded. Waiting for %3.f seconds...\n", delay.Seconds()) - time.Sleep(delay) -} - -func fetchData(url string, count *int) ([]User, error) { - var allData []User - - for url != "" { - resp, err := makeRequest(url) - if err != nil { - return nil, err - } - - if resp.StatusCode == http.StatusTooManyRequests { - handleRateLimit(count) - continue - } - - var users []User - if err := json.NewDecoder(resp.Body).Decode(&users); err != nil { - return nil, err - } - - allData = append(allData, users...) - url = getNextURL(resp) - } - return allData, nil -} - -func fetchFollowers(user string, count *int) ([]User, error) { - url := fmt.Sprintf("https://api.github.com/users/%s/followers?per_page=100", user) - return fetchData(url, count) -} - -func fetchFollowing(user string, count *int) ([]User, error) { - url := fmt.Sprintf("https://api.github.com/users/%s/following?per_page=100", user) - return fetchData(url, count) -} - -func followUser(user string, count *int, wg *sync.WaitGroup) { - defer wg.Done() - - url := fmt.Sprintf("https://api.github.com/user/following/%s", user) - req, err := http.NewRequest("PUT", url, nil) - if err != nil { - fmt.Printf("Error following user %s: %v\n", user, err) - return - } - req.Header.Set("Authorization", "token "+personalGithubToken) - resp, err := http.DefaultClient.Do(req) - if err != nil { - fmt.Printf("Error following user %s: %v\n", user, err) - return - } - defer resp.Body.Close() - - switch resp.StatusCode { - case http.StatusNoContent: - fmt.Printf("User: %s has been followed!\n", user) - case http.StatusForbidden, http.StatusTooManyRequests: - handleRateLimit(count) - followUser(user, count, wg) - default: - fmt.Printf("Error following %s: %v\n", user, resp.Status) - } -} - -func unfollowUser(user string, count *int, wg *sync.WaitGroup) { - defer wg.Done() - - url := fmt.Sprintf("https://api.github.com/user/following/%s", user) - req, err := http.NewRequest("DELETE", url, nil) - if err != nil { - fmt.Printf("Error unfollowing user %s: %v\n", user, err) - return - } - req.Header.Set("Authorization", "token "+personalGithubToken) - - resp, err := http.DefaultClient.Do(req) - if err != nil { - fmt.Printf("Error unfollowing user %s: %v\n", user, err) - return - } - defer resp.Body.Close() - - switch resp.StatusCode { - case http.StatusNoContent: - fmt.Printf("User: %s has been unfollowed!\n", user) - case http.StatusForbidden, http.StatusTooManyRequests: - handleRateLimit(count) - unfollowUser(user, count, wg) - default: - fmt.Printf("Error unfollowing %s: %v\n", user, resp.Status) - } -} - -func processUsers(users []string, command string) { - var wg sync.WaitGroup - count := 0 - for _, user := range users { - wg.Add(1) - if command == "unfollow" { - go unfollowUser(user, &count, &wg) - } else if command == "follow" { - go followUser(user, &count, &wg) - } - err := utils.LogFollowUnfollow(user, command) - if err != nil { - fmt.Println("Error loggin"+command+"action:", err) - } - } - wg.Wait() -} - -func makeRequest(url string) (*http.Response, error) { - req, err := http.NewRequest("GET", url, nil) - if err != nil { - return nil, err - } - req.Header.Set("Authorization", "token "+personalGithubToken) - resp, err := http.DefaultClient.Do(req) - if err != nil { - return nil, err - } - return resp, nil -} - -func getNextURL(resp *http.Response) string { - linkHeader := resp.Header.Get("Link") - if linkHeader == "" { - return "" - } - - // Split the header by comma to handle multiple links - links := strings.Split(linkHeader, ",") - - // Regular expression to capture the URL and its rel type - re := regexp.MustCompile(`<([^>]+)>;\s*rel="([^"]+)"`) - - // Iterate over each link - for _, link := range links { - matches := re.FindStringSubmatch(link) - if len(matches) == 3 && matches[2] == "next" { - // Return the URL if the rel type is "next" - return matches[1] - } - } - - // Return an empty string if no "next" link is found - return "" + utils.GetToken() } func main() { command := os.Args[1] - var targetUser string + + user := model.MyUser{} + user.Token = utils.GetToken() + user.Login = utils.GetUser(user.Token) + user.FetchFollowers(new(int)) + user.FetchFollowing(new(int)) + //TODO: implement user.FetchList to allow and deny list + if len(os.Args) > 2 { - targetUser = os.Args[2] + user.TargetUser = os.Args[2] } // Capture termination signals @@ -206,46 +45,14 @@ func main() { switch command { case "unfollow": - following, _ := fetchFollowing(utils.GetUser(personalGithubToken), new(int)) - followers, _ := fetchFollowers(utils.GetUser(personalGithubToken), new(int)) - var usersToUnfollow []string - for _, user := range following { - if !userInList(user, followers) { - usersToUnfollow = append(usersToUnfollow, user.Login) - } - } - processUsers(usersToUnfollow, "unfollow") + user.Unfollow() case "followers": - followers, _ := fetchFollowers(utils.GetUser(personalGithubToken), new(int)) - fmt.Printf("You have %d followers.\n", len(followers)) + fmt.Printf("You have %d followers.\n", len(user.Followers)) case "following": - following, _ := fetchFollowing(utils.GetUser(personalGithubToken), new(int)) - fmt.Printf("You follow %d users.\n", len(following)) + fmt.Printf("You follow %d users.\n", len(user.Following)) case "follow": - if targetUser == "" { - fmt.Print("User to fetch? ") - fmt.Scanln(&targetUser) - } - followers, _ := fetchFollowers(targetUser, new(int)) - following, _ := fetchFollowing(utils.GetUser(personalGithubToken), new(int)) - var usersToFollow []string - for _, user := range followers { - if !userInList(user, following) { - usersToFollow = append(usersToFollow, user.Login) - } - } - processUsers(usersToFollow, "follow") + user.Follow() default: fmt.Println("Invalid command") } } - -func userInList(user User, list []User) bool { - for _, u := range list { - if u.Login == user.Login { - return true - } - } - - return false -} diff --git a/internal/app/model/user.go b/internal/app/model/user.go index 6ba0ebc..b4e3c0d 100644 --- a/internal/app/model/user.go +++ b/internal/app/model/user.go @@ -1,14 +1,30 @@ package model +import ( + "encoding/json" + "fmt" + "io" + "log" + "net/http" + "sync" + + "github.com/0bvim/goctobot/utils" +) + +const ( + FOLLOWING_URL = "https://api.github.com/users/%s/following?per_page=100" + FOLLOWERS_URL = "https://api.github.com/users/%s/followers?per_page=100" +) + // Main user struct type MyUser struct { Followers []User - Following []User - Allowed []User Denied []User + Following []User Login string `json:"login"` TargetUser string - token string + Token string + UserStatus map[string]string } // a single user struct @@ -16,18 +32,163 @@ type User struct { Login string `json:"login"` } -func (u *MyUser) FetchFollowing() { - //TODO: Implement this function +func (u *MyUser) FetchFollowing(count *int) { + var url string + if u.TargetUser != "" { + url = fmt.Sprintf(FOLLOWING_URL, u.TargetUser) + } else { + url = fmt.Sprintf(FOLLOWING_URL, u.Login) + } + + fetchData(url, "following", u, count) } func (u *MyUser) FetchFollowers(count *int) { - //TODO: Implement this function + url := fmt.Sprintf(FOLLOWERS_URL, u.Login) + fetchData(url, "followers", u, count) } func (u *MyUser) Unfollow() { - //TODO: Implement this function + var usersToUnfollow []string + for _, user := range u.Following { + if !userInList(user, u.Followers) || u.UserStatus[user.Login] == "Allow" { + usersToUnfollow = append(usersToUnfollow, user.Login) + } + } + + processUsers(usersToUnfollow, "unfollow") } func (u *MyUser) Follow() { - //TODO: Implement this function + if u.TargetUser == "" { + fmt.Print("User to fetch? ") + fmt.Scanln(&u.TargetUser) + } + u.FetchFollowers(new(int)) + + var usersToFollow []string + for _, user := range u.Followers { + if !userInList(user, u.Following) || u.UserStatus[user.Login] == "Deny" { + usersToFollow = append(usersToFollow, user.Login) + } + } + + processUsers(usersToFollow, "follow") +} + +func fetchData(url, action string, u *MyUser, count *int) { + for url != "" { + resp, err := utils.FetchRequest(url) + if err != nil { + log.Fatalf("Error in request %s", err) + } + + if resp.StatusCode == http.StatusTooManyRequests { + utils.HandleRateLimit(count) + continue + } + + var users []User + if err := json.NewDecoder(resp.Body).Decode(&users); err != nil { + body, err := io.ReadAll(resp.Body) + fmt.Printf("body: %v\n", body) + log.Fatalf("Error in request %s", err) + } + + switch action { + case "followers": + u.Followers = append(u.Followers, users...) + case "following": + u.Following = append(u.Following, users...) + } + + url = utils.GetNextURL(resp) + } +} + +func processUsers(users []string, command string) { + var wg sync.WaitGroup + count := 0 + for _, user := range users { + wg.Add(1) + switch command { + case "unfollow": + go unfollowUser(user, &count, &wg) + case "follow": + go followUser(user, &count, &wg) + } + err := utils.LogFollowUnfollow(user, command) + if err != nil { + fmt.Println("Error loggin"+command+"action:", err) + } + } + wg.Wait() +} + +func userInList(user User, list []User) bool { + for _, u := range list { + if u.Login == user.Login { + return true + } + } + + return false +} + +func unfollowUser(user string, count *int, wg *sync.WaitGroup) { + defer wg.Done() + + url := fmt.Sprintf("https://api.github.com/user/following/%s", user) + req, err := http.NewRequest("DELETE", url, nil) + if err != nil { + fmt.Printf("Error unfollowing user %s: %v\n", user, err) + return + } + req.Header.Set("Authorization", "token "+utils.GetToken()) + + resp, err := http.DefaultClient.Do(req) + if err != nil { + fmt.Printf("Error unfollowing user %s: %v\n", user, err) + return + } + defer resp.Body.Close() + + switch resp.StatusCode { + case http.StatusNoContent: + fmt.Printf("User: %s has been unfollowed!\n", user) + case http.StatusForbidden, http.StatusTooManyRequests: + utils.HandleRateLimit(count) + unfollowUser(user, count, wg) + default: + fmt.Printf("Error unfollowing %s: %v\n", user, resp.Status) + } +} + +func followUser(user string, count *int, wg *sync.WaitGroup) { + defer wg.Done() + + url := fmt.Sprintf("https://api.github.com/user/following/%s", user) + req, err := http.NewRequest("PUT", url, nil) + if err != nil { + fmt.Printf("Error following user %s: %v\n", user, err) + return + } + req.Header.Set("Authorization", "token "+utils.GetToken()) + + resp, err := http.DefaultClient.Do(req) + if err != nil { + fmt.Printf("Error following user %s: %v\n", user, err) + return + } + defer resp.Body.Close() + + switch resp.StatusCode { + case http.StatusNoContent: + fmt.Printf("User: %s has been followed!\n", user) + case http.StatusForbidden, http.StatusTooManyRequests: + utils.HandleRateLimit(count) + followUser(user, count, wg) + default: + fmt.Printf("Error following %s: %v\n", user, resp.Status) + } } diff --git a/utils/token_utils.go b/utils/token_utils.go new file mode 100644 index 0000000..0d732c7 --- /dev/null +++ b/utils/token_utils.go @@ -0,0 +1,85 @@ +package utils + +import ( + "fmt" + "log" + "net/http" + "os" +) + +func printInvalidToken() { + Colorize(Red, "Error: 'personal_github_token' environment variable not set.") + Colorize(Magenta, "To solve this: ") + Colorize(Green, ` + 1. Generate a GitHub personal access token with the 'user:follow' and 'read:user' scopes at https://github.com/settings/tokens. + 2. Set the token in your environment with: + export personal_github_token="your_token_here" + 3. To make this change permanent, add it to your '~/.bashrc' with: + echo 'export personal_github_token="your_token_here"' >> ~/.bashrc + source ~/.bashrc + + After setting up the token, you can run OctoBot commands with: + goctobot [username] + + For more details, visit the GitHub repository.`) +} + +func ValidToken(token string) error { + resp, err := LoginRequest(token) + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("token validation failed: received status code %d", resp.StatusCode) + } + + return nil +} + +func GetToken() string { + personalGithubToken := os.Getenv("personal_github_token") + + err := ValidToken(personalGithubToken) + if err != nil { + printInvalidToken() + os.Exit(1) + } + + return personalGithubToken +} + +func LoginRequest(token string) (*http.Response, error) { + req, err := http.NewRequest("GET", "https://api.github.com/user", nil) + if err != nil { + log.Fatalf("Error creating request: %v", err) + } + + req.Header.Set("Authorization", "token "+token) + + client := &http.Client{} + resp, err := client.Do(req) + if err != nil { + log.Fatalf("Error sending request: %v", err) + } + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("token validation failed: received status code %d", resp.StatusCode) + } + + return resp, nil +} + +func FetchRequest(url string) (*http.Response, error) { + req, err := http.NewRequest("GET", url, nil) + if err != nil { + return nil, err + } + req.Header.Set("Authorization", "token "+GetToken()) + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil, err + } + return resp, nil +} diff --git a/utils/utils.go b/utils/utils.go index 98c2428..9b3400a 100644 --- a/utils/utils.go +++ b/utils/utils.go @@ -6,6 +6,8 @@ import ( "log" "net/http" "os" + "regexp" + "strings" "time" ) @@ -18,78 +20,28 @@ const ( ) // Function to wrap text with color codes -func Colorize(color string, text string) string { - return color + text + Reset +func Colorize(color string, text string) { + fmt.Println(color + text + Reset) } func PrintHelp() { - fmt.Println(Colorize(Magenta, "Commands:")) - fmt.Println(Colorize(Red, "- follow : Follow a GitHub user")) - fmt.Println(Colorize(Red, "- unfollow: Unfollow a user")) - fmt.Println(Colorize(Red, "- following: List users you're following")) - fmt.Println(Colorize(Red, "- followers: List your followers")) -} - -func ValidToken(token string) bool { - if token == "" { - fmt.Println(Colorize(Red, "Error: 'personal_github_token' environment variable not set.")) - fmt.Println(Colorize(Magenta, "To solve this: ")) - fmt.Println(Colorize(Green, ` - 1. Generate a GitHub personal access token with the 'user:follow' and 'read:user' scopes at https://github.com/settings/tokens. - 2. Set the token in your environment with: - export personal_github_token="your_token_here" - 3. To make this change permanent, add it to your '~/.bashrc' with: - echo 'export personal_github_token="your_token_here"' >> ~/.bashrc - source ~/.bashrc - - After setting up the token, you can run OctoBot commands with: - ghbot [username] - - For more details, visit the GitHub repository.`)) - return false - } - return true -} - -func GetToken() string { - personalGithubToken := os.Getenv("personal_github_token") - if !ValidToken(personalGithubToken) { - os.Exit(1) - } - return personalGithubToken + Colorize(Magenta, "Commands:") + Colorize(Green, "- follow : Follow a GitHub user") + Colorize(Green, "- unfollow: Unfollow users that not follow you back") + Colorize(Green, "- following: List users you're following") + Colorize(Green, "- followers: List your followers") } func GetUser(token string) string { - // creating a new request - req, err := http.NewRequest("GET", "https://api.github.com/user", nil) - if err != nil { - log.Fatalf("Error creating request: %v", err) - } - - // set authorization header - req.Header.Set("Authorization", "token "+token) - - //send the request - client := &http.Client{} - resp, err := client.Do(req) - if err != nil { - log.Fatalf("Error sending request: %v", err) - } + resp, _ := LoginRequest(token) defer resp.Body.Close() - //check if response is OK - if resp.StatusCode != http.StatusOK { - log.Fatalf("Unexpected status doe: %d", resp.StatusCode) - } - - // parsing responde body var result map[string]interface{} if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { log.Fatalf("Error decoding JSON response: %v", err) } var user string - // extract the Github login if login, ok := result["login"].(string); ok { user = login } else { @@ -101,20 +53,15 @@ func GetUser(token string) string { // logFollowUnfollow logs the action of following or unfollowing a user with a timestamp. func LogFollowUnfollow(username, action string) error { - // Open the log file in append mode, or create it if it doesn't exist file, err := os.OpenFile("log.txt", os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) if err != nil { return err } defer file.Close() - // Get the current time for the timestamp timestamp := time.Now().Format("2006-01-02 15:04:05") - - // Create the log entry logEntry := fmt.Sprintf("[%s] %s: %s\n", timestamp, action, username) - // Write the log entry to the file _, err = file.WriteString(logEntry) if err != nil { return err @@ -122,3 +69,29 @@ func LogFollowUnfollow(username, action string) error { return nil } + +func HandleRateLimit(count *int) { + *count++ + delay := time.Duration(60*(*count)) * time.Second + fmt.Printf("Rate limit exceeded. Waiting for %3.f seconds...\n", delay.Seconds()) + time.Sleep(delay) +} + +func GetNextURL(resp *http.Response) string { + linkHeader := resp.Header.Get("Link") + if linkHeader == "" { + return "" + } + + links := strings.Split(linkHeader, ",") + re := regexp.MustCompile(`<([^>]+)>;\s*rel="([^"]+)"`) + + for _, link := range links { + matches := re.FindStringSubmatch(link) + if len(matches) == 3 && matches[2] == "next" { + return matches[1] + } + } + + return "" +} diff --git a/utils/utils_test.go b/utils/utils_test.go index b47164a..4dec820 100644 --- a/utils/utils_test.go +++ b/utils/utils_test.go @@ -1,29 +1,24 @@ package utils -import ( - "fmt" - "testing" -) - -func TestColorized(t *testing.T) { - tests := []struct { - color string - text string - expect string - }{ - {Red, "Hello", Red + "Hello" + Reset}, - {Green, "World", Green + "World" + Reset}, - } - - for _, tt := range tests { - t.Run(fmt.Sprintf("Colorize(%q, %q)", tt.color, tt.text), func(t *testing.T) { - result := Colorize(tt.color, tt.text) - if result != tt.expect { - t.Errorf("Colorize(%q, %q) = %q; want %q", tt.color, tt.text, result, tt.expect) - } - }) - } -} +// func TestColorized(t *testing.T) { +// tests := []struct { +// color string +// text string +// expect string +// }{ +// {Red, "Hello", Red + "Hello" + Reset}, +// {Green, "World", Green + "World" + Reset}, +// } +// +// for _, tt := range tests { +// t.Run(fmt.Sprintf("Colorize(%q, %q)", tt.color, tt.text), func(t *testing.T) { +// result := Colorize(tt.color, tt.text) +// if result != tt.expect { +// t.Errorf("Colorize(%q, %q) = %q; want %q", tt.color, tt.text, result, tt.expect) +// } +// }) +// } +// } // func TestGetToken(t *testing.T) { // // Save the original value of the environment variable to restore later