├── .gitignore ├── LICENSE ├── Makefile ├── README.md ├── config.go ├── config_test.go ├── examples └── create_post │ ├── Makefile │ ├── go.mod │ ├── go.sum │ └── main.go ├── go.mod ├── go.sum ├── model ├── auth.go ├── create.go ├── follow.go ├── like.go ├── thread.go └── user.go ├── private ├── auth.go ├── constant.go ├── create.go ├── follow.go ├── get_detail.go ├── get_follower.go ├── get_following.go ├── like.go ├── search.go ├── service.go └── unfollow.go ├── public.go ├── public ├── get_likers.go └── service.go ├── thread.go └── util ├── util.go └── util_test.go /.gitignore: -------------------------------------------------------------------------------- 1 | ### Go ### 2 | # If you prefer the allow list template instead of the deny list, see community template: 3 | # https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore 4 | # 5 | # Binaries for programs and plugins 6 | *.exe 7 | *.exe~ 8 | *.dll 9 | *.so 10 | *.dylib 11 | 12 | # Test binary, built with `go test -c` 13 | *.test 14 | 15 | # Output of the go coverage tool, specifically when used with LiteIDE 16 | *.out 17 | 18 | # Dependency directories (remove the comment below to include it) 19 | # vendor/ 20 | 21 | # Go workspace file 22 | go.work 23 | 24 | ### macOS ### 25 | # General 26 | .DS_Store 27 | .AppleDouble 28 | .LSOverride 29 | .idea 30 | 31 | # Icon must end with two \r 32 | Icon 33 | 34 | 35 | # Thumbnails 36 | ._* 37 | 38 | # Files that might appear in the root of a volume 39 | .DocumentRevisions-V100 40 | .fseventsd 41 | .Spotlight-V100 42 | .TemporaryItems 43 | .Trashes 44 | .VolumeIcon.icns 45 | .com.apple.timemachine.donotpresent 46 | 47 | # Directories potentially created on remote AFP share 48 | .AppleDB 49 | .AppleDesktop 50 | Network Trash Folder 51 | Temporary Items 52 | .apdisk 53 | 54 | ### macOS Patch ### 55 | # iCloud generated files 56 | *.icloud 57 | 58 | # End of https://www.toptal.com/developers/gitignore/api/go,macos -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Dwarves Foundation 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | test: 2 | go test ./... 3 | 4 | run-example-create: 5 | go run ./examples/create_post/main.go -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | Dwarves Golang Threads API 3 |

4 |

5 | 6 | Dwarves Foundation 7 | 8 | 9 | Dwarves Foundation Discord 10 | 11 |

12 | 13 | Unofficial, Reverse-Engineered Golang client for Meta's Threads. Supports Read and Write. 14 | 15 | ## Getting started 16 | How to install 17 | Install the library with the following command using go module: 18 | 19 | ``` 20 | $ go get github.com/dwarvesf/go-threads 21 | ``` 22 | 23 | Examples 24 | Find examples of how to use the library in the examples folder: 25 | 26 | ``` 27 | ls examples 28 | ├── create_post 29 | │ └── main.go 30 | ... 31 | ``` 32 | 33 | ## API 34 | 35 | ### Disclaimer 36 | 37 | The Threads API is a public API in the library, requiring no authorization. In contrast, the Instagram API, referred to as the private API, requires the Instagram username and password for interaction. 38 | 39 | The public API offers read-only endpoints, while the private API provides both read and write endpoints. The private API is generally more stable as Instagram is a reliable product. 40 | 41 | Using the public API reduces the risk of rate limits or account suspension. However, there is a trade-off between stability, bugs, rate limits, and suspension. The library allows for combining approaches, such as using the public API for read-only tasks and the private API for write operations. A retry mechanism can also be implemented, attempting the public API first and then falling back to the private API if necessary. 42 | 43 | ### Initialization 44 | 45 | To start using the `GoThreads` package, import the relevant class for communication with the Threads API and create an instance of the object. 46 | 47 | For utilizing only the public API, use the following code snippet: 48 | 49 | ```go 50 | import ( 51 | "github.com/dwarvesf/go-threads" 52 | ) 53 | 54 | func main() { 55 | th := threads.NewThreads() 56 | th.GetThreadLikers() 57 | 58 | // Using global instance 59 | threads.GetThreadLikers() 60 | } 61 | ``` 62 | 63 | If you intend to use the private API exclusively or both the private and public APIs, utilize the following code snippet: 64 | 65 | ```go 66 | package main 67 | 68 | import ( 69 | "fmt" 70 | 71 | "github.com/dwarvesf/go-threads" 72 | ) 73 | 74 | func main() { 75 | cfg, err := threads.InitConfig( 76 | threads.WithDoLogin("instagram_username", "instagram_password"), 77 | ) 78 | if err != nil { 79 | fmt.Println("unable init config", err) 80 | return 81 | } 82 | 83 | client, err := threads.NewPrivateAPIClient(cfg) 84 | if err != nil { 85 | fmt.Println("unable init API client", err) 86 | return 87 | } 88 | } 89 | ``` 90 | 91 | Or the shorter syntax 92 | 93 | ```go 94 | package main 95 | 96 | import ( 97 | "fmt" 98 | 99 | "github.com/dwarvesf/go-threads" 100 | "github.com/dwarvesf/go-threads/model" 101 | ) 102 | 103 | func main() { 104 | client, err := threads.InitAPIClient( 105 | threads.WithDoLogin("instagram_username", "instagram_password"), 106 | ) 107 | if err != nil { 108 | fmt.Println("unable init API client", err) 109 | return 110 | } 111 | 112 | p, err := client.CreatePost(model.CreatePostRequest{Caption: "new post"}) 113 | if err != nil { 114 | fmt.Println("unable create a post", err) 115 | return 116 | } 117 | } 118 | ``` 119 | 120 | To mitigate the risk of blocking our users, an alternative initialization method can be implemented for the client. This method entails storing the API token and device token, which are subsequently utilized for initializing the API client. 121 | 122 | ```go 123 | package main 124 | 125 | import ( 126 | "fmt" 127 | 128 | "github.com/dwarvesf/go-threads" 129 | "github.com/dwarvesf/go-threads/model" 130 | ) 131 | 132 | func main() { 133 | client, err := threads.InitAPIClient( 134 | threads.WithCridential("instagram_username", "instagram_password"), 135 | threads.WithAPIToken("device_id", "api_token"), 136 | ) 137 | if err != nil { 138 | fmt.Println("unable init API client", err) 139 | return 140 | } 141 | 142 | p, err := client.CreatePost(model.CreatePostRequest{Caption: "new post"}) 143 | if err != nil { 144 | fmt.Println("unable create a post", err) 145 | return 146 | } 147 | } 148 | ``` 149 | 150 | ### Public API 151 | 152 | `Coming soon` 153 | 154 | ### Private API 155 | 156 | `Coming soon` 157 | 158 | ## Road map 159 | 160 | - [ ] Improve the perfomance 161 | - [ ] Listing API 162 | - [ ] Mutation API 163 | 164 | -------------------------------------------------------------------------------- /config.go: -------------------------------------------------------------------------------- 1 | package threads 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | 7 | "github.com/dwarvesf/go-threads/private" 8 | "github.com/dwarvesf/go-threads/util" 9 | ) 10 | 11 | // Config config to init thread client 12 | type Config struct { 13 | Username string 14 | Password string 15 | UserID int 16 | APIToken string 17 | TimezoneOffset int 18 | DeviceID string 19 | DeviceModel string 20 | DeviceManufacturer string 21 | DeviceOsVersion int 22 | DeviceOsRelease string 23 | } 24 | 25 | func (c Config) ReadyCheck() error { 26 | if c.Username == "" || c.Password == "" { 27 | return errors.New("credential is required") 28 | } 29 | 30 | if c.UserID == 0 { 31 | return errors.New("user id is required") 32 | } 33 | 34 | if c.APIToken == "" { 35 | return errors.New("api token is empty") 36 | } 37 | return nil 38 | } 39 | 40 | // ConfigFn define the function to update config data 41 | type ConfigFn func(Config) (Config, error) 42 | 43 | // InitConfig init configs 44 | func InitConfig(configs ...ConfigFn) (*Config, error) { 45 | configs = append([]ConfigFn{WithDefaultValue()}, configs...) 46 | 47 | c := Config{} 48 | for idx := range configs { 49 | fn := configs[idx] 50 | 51 | tmp, err := fn(c) 52 | if err != nil { 53 | return nil, err 54 | } 55 | c = tmp 56 | } 57 | 58 | return &c, nil 59 | } 60 | 61 | // WithDefaultValue initial default value 62 | func WithDefaultValue() ConfigFn { 63 | return func(c Config) (Config, error) { 64 | c.TimezoneOffset = -14400 65 | c.DeviceID = util.GenerateAndroidDeviceID() 66 | c.DeviceManufacturer = "OnePlus" 67 | c.DeviceModel = "ONEPLUS+A3010" 68 | c.DeviceOsVersion = 25 69 | c.DeviceOsRelease = "7.1.1" 70 | 71 | return c, nil 72 | } 73 | } 74 | 75 | // WithCridential update the user cridential 76 | func WithCridential(username string, password string) ConfigFn { 77 | return func(c Config) (Config, error) { 78 | if c.UserID == 0 { 79 | tmp, err := WithUserIDFetching(username)(c) 80 | if err != nil { 81 | return c, err 82 | } 83 | c = tmp 84 | } 85 | c.Username = username 86 | c.Password = password 87 | return c, nil 88 | } 89 | } 90 | 91 | // WithUserID update the user ID 92 | func WithUserID(userID int) ConfigFn { 93 | return func(c Config) (Config, error) { 94 | c.UserID = userID 95 | return c, nil 96 | } 97 | } 98 | 99 | // WithDeviceID update the device ID 100 | func WithDeviceID(deviceID string) ConfigFn { 101 | return func(c Config) (Config, error) { 102 | c.DeviceID = deviceID 103 | return c, nil 104 | } 105 | } 106 | 107 | // WithUserIDFetching fetch user id from instagram 108 | func WithUserIDFetching(username string) ConfigFn { 109 | return func(c Config) (Config, error) { 110 | 111 | userID, err := GetUserByUsername(username) 112 | if err != nil { 113 | return c, fmt.Errorf("unable fetch user id: %v", err) 114 | } 115 | 116 | c.UserID = userID 117 | c.Username = username 118 | 119 | return c, nil 120 | } 121 | } 122 | 123 | // WithAPIToken 124 | func WithAPIToken(deviceID string, apitoken string) ConfigFn { 125 | return func(c Config) (Config, error) { 126 | c.DeviceID = deviceID 127 | c.APIToken = apitoken 128 | return c, nil 129 | } 130 | } 131 | 132 | // WithDoLogin fetch user id from instagram 133 | func WithDoLogin(username string, password string) ConfigFn { 134 | return func(c Config) (Config, error) { 135 | 136 | if c.DeviceID == "" { 137 | cTemp, err := WithDeviceID(util.GenerateAndroidDeviceID())(c) 138 | if err != nil { 139 | return c, err 140 | } 141 | c = cTemp 142 | } 143 | if c.UserID == 0 { 144 | cTemp, err := WithUserIDFetching(username)(c) 145 | if err != nil { 146 | return c, err 147 | } 148 | c = cTemp 149 | } 150 | if password == "" { 151 | return c, errors.New("passowrd is empty") 152 | } 153 | 154 | if c.APIToken != "" { 155 | ar, err := private.Auth(username, password, c.DeviceID) 156 | if err != nil { 157 | return c, err 158 | } 159 | 160 | c.APIToken = ar.Token 161 | } 162 | c.Username = username 163 | c.Password = password 164 | 165 | return c, nil 166 | } 167 | } 168 | -------------------------------------------------------------------------------- /config_test.go: -------------------------------------------------------------------------------- 1 | package threads 2 | 3 | import ( 4 | "reflect" 5 | "testing" 6 | ) 7 | 8 | func TestInitConfig(t *testing.T) { 9 | type args struct { 10 | configs []ConfigFn 11 | } 12 | tests := []struct { 13 | name string 14 | args args 15 | want *Config 16 | wantErr bool 17 | }{ 18 | { 19 | name: "update credential", 20 | args: args{[]ConfigFn{WithCridential("username", "password")}}, 21 | want: &Config{}, 22 | wantErr: false, 23 | }, 24 | { 25 | name: "update device id", 26 | args: args{[]ConfigFn{WithDeviceID("deviceID1")}}, 27 | want: &Config{ 28 | DeviceID: "deviceID1", 29 | }, 30 | wantErr: false, 31 | }, 32 | { 33 | name: "update user id", 34 | args: args{[]ConfigFn{WithUserID(1)}}, 35 | want: &Config{ 36 | UserID: 1, 37 | }, 38 | wantErr: false, 39 | }, 40 | } 41 | for _, tt := range tests { 42 | t.Run(tt.name, func(t *testing.T) { 43 | got, err := InitConfig(tt.args.configs...) 44 | if (err != nil) != tt.wantErr { 45 | t.Errorf("InitConfig() error = %v, wantErr %v", err, tt.wantErr) 46 | return 47 | } 48 | if !reflect.DeepEqual(got, tt.want) { 49 | t.Errorf("InitConfig() = %v, want %v", got, tt.want) 50 | } 51 | }) 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /examples/create_post/Makefile: -------------------------------------------------------------------------------- 1 | run: 2 | go run ./main.go -------------------------------------------------------------------------------- /examples/create_post/go.mod: -------------------------------------------------------------------------------- 1 | module example/create-post 2 | 3 | go 1.20 4 | 5 | replace github.com/dwarvesf/go-threads => ../../ 6 | 7 | require github.com/dwarvesf/go-threads v0.0.1 8 | 9 | require github.com/google/uuid v1.3.0 // indirect 10 | -------------------------------------------------------------------------------- /examples/create_post/go.sum: -------------------------------------------------------------------------------- 1 | github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I= 2 | github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 3 | -------------------------------------------------------------------------------- /examples/create_post/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/dwarvesf/go-threads" 7 | "github.com/dwarvesf/go-threads/model" 8 | ) 9 | 10 | func main() { 11 | cfg, err := threads.InitConfig( 12 | threads.WithDoLogin("instagram_username", "instagram_password"), 13 | ) 14 | 15 | if err != nil { 16 | fmt.Println("unable init config", err) 17 | return 18 | } 19 | 20 | client, err := threads.NewPrivateAPIClient(cfg) 21 | 22 | if err != nil { 23 | fmt.Println("unable init API client", err) 24 | return 25 | } 26 | 27 | p, err := client.CreatePost(model.CreatePostRequest{Caption: "hello threads"}) 28 | if err != nil { 29 | fmt.Println("unable create a post", err) 30 | return 31 | } 32 | 33 | fmt.Println(p.Media.Pk) 34 | } 35 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/dwarvesf/go-threads 2 | 3 | go 1.20 4 | 5 | require github.com/google/uuid v1.3.0 6 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I= 2 | github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 3 | -------------------------------------------------------------------------------- /model/auth.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | type AuthResponse struct { 4 | Token string 5 | } 6 | -------------------------------------------------------------------------------- /model/create.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | type CreatePostRequest struct { 4 | Caption string 5 | } 6 | 7 | type CreatePostResponse struct { 8 | Media Media `json:"media,omitempty"` 9 | } 10 | -------------------------------------------------------------------------------- /model/follow.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | type UserSummary struct { 4 | HasAnonymousProfilePicture bool `json:"has_anonymous_profile_picture"` 5 | FBIDV2 interface{} `json:"fbid_v2"` 6 | HasOnboardedToTextPostApp bool `json:"has_onboarded_to_text_post_app"` 7 | TextPostAppJoinerNumber int `json:"text_post_app_joiner_number"` 8 | PK int64 `json:"pk"` 9 | PKID string `json:"pk_id"` 10 | Username string `json:"username"` 11 | FullName string `json:"full_name"` 12 | IsPrivate bool `json:"is_private"` 13 | IsVerified bool `json:"is_verified"` 14 | ProfilePicID string `json:"profile_pic_id"` 15 | ProfilePicURL string `json:"profile_pic_url"` 16 | AccountBadges []string `json:"account_badges"` 17 | IsPossibleScammer bool `json:"is_possible_scammer"` 18 | ThirdPartyDownloadsEnabled int `json:"third_party_downloads_enabled"` 19 | IsPossibleBadActor struct { 20 | IsPossibleScammer bool `json:"is_possible_scammer"` 21 | IsPossibleImpersonator struct { 22 | IsUnconnectedImpersonator bool `json:"is_unconnected_impersonator"` 23 | } `json:"is_possible_impersonator"` 24 | } `json:"is_possible_bad_actor"` 25 | LatestReelMedia int `json:"latest_reel_media"` 26 | } 27 | 28 | type UserFollowersResponse struct { 29 | Users []UserSummary `json:"users"` 30 | BigList bool `json:"big_list"` 31 | PageSize int `json:"page_size"` 32 | Groups []interface{} `json:"groups"` 33 | MoreGroupsAvailable bool `json:"more_groups_available"` 34 | FriendRequests map[string]int `json:"friend_requests"` 35 | HasMore bool `json:"has_more"` 36 | ShouldLimitListOfFollowers bool `json:"should_limit_list_of_followers"` 37 | Status string `json:"status"` 38 | } 39 | 40 | type UserFollowingResponse struct { 41 | Users []UserSummary `json:"users"` 42 | BigList bool `json:"big_list"` 43 | PageSize int `json:"page_size"` 44 | HasMore bool `json:"has_more"` 45 | ShouldLimitListOfFollowers bool `json:"should_limit_list_of_followers"` 46 | Status string `json:"status"` 47 | } 48 | 49 | type FriendshipStatus struct { 50 | Following bool `json:"following"` 51 | FollowedBy bool `json:"followed_by"` 52 | Blocking bool `json:"blocking"` 53 | Muting bool `json:"muting"` 54 | IsPrivate bool `json:"is_private"` 55 | IncomingRequest bool `json:"incoming_request"` 56 | OutgoingRequest bool `json:"outgoing_request"` 57 | TextPostAppPreFollowing bool `json:"text_post_app_pre_following"` 58 | IsBestie bool `json:"is_bestie"` 59 | IsRestricted bool `json:"is_restricted"` 60 | IsFeedFavorite bool `json:"is_feed_favorite"` 61 | IsEligibleToSubscribe bool `json:"is_eligible_to_subscribe"` 62 | } 63 | 64 | type FollowUserResponse struct { 65 | FriendshipStatus FriendshipStatus `json:"friendship_status"` 66 | PreviousFollowing bool `json:"previous_following"` 67 | Status string `json:"status"` 68 | } 69 | -------------------------------------------------------------------------------- /model/like.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | type LikersResponse struct { 4 | Data struct { 5 | Likers struct { 6 | Users []UserInfo `json:"users"` 7 | } `json:"likers"` 8 | } `json:"data"` 9 | Extensions struct { 10 | IsFinal bool `json:"is_final"` 11 | } `json:"extensions"` 12 | } 13 | 14 | type UserInfo struct { 15 | PK string `json:"pk"` 16 | FullName string `json:"full_name"` 17 | ProfilePicURL string `json:"profile_pic_url"` 18 | FollowerCount int `json:"follower_count"` 19 | IsVerified bool `json:"is_verified"` 20 | Username string `json:"username"` 21 | ProfileContextFacepile interface{} `json:"profile_context_facepile_users"` 22 | ID interface{} `json:"id"` 23 | } 24 | -------------------------------------------------------------------------------- /model/thread.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | type Post struct { 4 | Pk int64 `json:"pk"` 5 | ID string `json:"id"` 6 | TextPostAppInfo TextPostAppInfo `json:"text_post_app_info"` 7 | Caption Caption `json:"caption"` 8 | TakenAt int `json:"taken_at"` 9 | DeviceTimestamp int `json:"device_timestamp"` 10 | MediaType int `json:"media_type"` 11 | Code string `json:"code"` 12 | ClientCacheKey string `json:"client_cache_key"` 13 | FilterType int `json:"filter_type"` 14 | ProductType string `json:"product_type"` 15 | OrganicTrackingToken string `json:"organic_tracking_token"` 16 | ImageVersions2 struct { 17 | Candidates []struct { 18 | Width int `json:"width"` 19 | Height int `json:"height"` 20 | URL string `json:"url"` 21 | ScansProfile string `json:"scans_profile"` 22 | } `json:"candidates"` 23 | } `json:"image_versions2"` 24 | OriginalWidth int `json:"original_width"` 25 | OriginalHeight int `json:"original_height"` 26 | VideoVersions []string `json:"video_versions"` 27 | LikeCount int `json:"like_count"` 28 | TimezoneOffset int `json:"timezone_offset"` 29 | HasLiked bool `json:"has_liked"` 30 | LikeAndViewCountsDisabled bool `json:"like_and_view_counts_disabled"` 31 | CanViewerReshare bool `json:"can_viewer_reshare"` 32 | IntegrityReviewDecision string `json:"integrity_review_decision"` 33 | TopLikers []string `json:"top_likers"` 34 | User User `json:"user"` 35 | } 36 | 37 | type ThreadItem struct { 38 | Post Post `json:"post"` 39 | LineType string `json:"line_type"` 40 | ViewRepliesCtaString string `json:"view_replies_cta_string"` 41 | ShouldShowRepliesCta bool `json:"should_show_replies_cta"` 42 | ReplyFacepileUsers []string `json:"reply_facepile_users"` 43 | CanInlineExpandBelow bool `json:"can_inline_expand_below"` 44 | } 45 | 46 | type ReplyThread struct { 47 | ThreadItems []ThreadItem `json:"thread_items"` 48 | ThreadType string `json:"thread_type"` 49 | ShowCreateReplyCta bool `json:"show_create_reply_cta"` 50 | ID int64 `json:"id"` 51 | Posts []Post `json:"posts"` 52 | } 53 | 54 | type ThreadData struct { 55 | ContainingThread ReplyThread `json:"containing_thread"` 56 | ReplyThreads []ReplyThread `json:"reply_threads"` 57 | } 58 | 59 | type ThreadDetailResponse struct { 60 | Data ThreadData `json:"data"` 61 | ContainingThread ReplyThread `json:"containing_thread"` 62 | ReplyThreads []ReplyThread `json:"reply_threads"` 63 | SiblingThreads []interface{} `json:"sibling_threads"` 64 | PagingTokens map[string]string `json:"paging_tokens"` 65 | DownwardsThreadWillContinue bool `json:"downwards_thread_will_continue"` 66 | TargetPostReplyPlaceholder string `json:"target_post_reply_placeholder"` 67 | Status string `json:"status"` 68 | } 69 | 70 | type User struct { 71 | HasAnonymousProfilePicture bool `json:"has_anonymous_profile_picture"` 72 | FanClubInfo FanClubInfo `json:"fan_club_info"` 73 | FBIDV2 interface{} `json:"fbid_v2"` 74 | TransparencyProductEnabled bool `json:"transparency_product_enabled"` 75 | TextPostAppTakeABreakSetting int `json:"text_post_app_take_a_break_setting"` 76 | InteropMessagingUserFBID int64 `json:"interop_messaging_user_fbid"` 77 | ShowInsightsTerms bool `json:"show_insights_terms"` 78 | AllowedCommenterType string `json:"allowed_commenter_type"` 79 | IsUnpublished bool `json:"is_unpublished"` 80 | ReelAutoArchive string `json:"reel_auto_archive"` 81 | CanBoostPost bool `json:"can_boost_post"` 82 | CanSeeOrganicInsights bool `json:"can_see_organic_insights"` 83 | HasOnboardedToTextPostApp bool `json:"has_onboarded_to_text_post_app"` 84 | TextPostAppJoinerNumber int `json:"text_post_app_joiner_number"` 85 | Pk int64 `json:"pk"` 86 | PKID string `json:"pk_id"` 87 | Username string `json:"username"` 88 | FullName string `json:"full_name"` 89 | IsPrivate bool `json:"is_private"` 90 | ProfilePicURL string `json:"profile_pic_url"` 91 | AccountBadges []string `json:"account_badges"` 92 | FeedPostReshareDisabled bool `json:"feed_post_reshare_disabled"` 93 | ShowAccountTransparencyDetails bool `json:"show_account_transparency_details"` 94 | ThirdPartyDownloadsEnabled int `json:"third_party_downloads_enabled"` 95 | } 96 | 97 | type Media struct { 98 | TakenAt int64 `json:"taken_at"` 99 | Pk int64 `json:"pk"` 100 | ID string `json:"id"` 101 | DeviceTimestamp int64 `json:"device_timestamp"` 102 | MediaType int `json:"media_type"` 103 | Code string `json:"code"` 104 | ClientCacheKey string `json:"client_cache_key"` 105 | FilterType int `json:"filter_type"` 106 | CanViewerReshare bool `json:"can_viewer_reshare"` 107 | Caption Caption `json:"caption"` 108 | ClipsTabPinnedUserIDs []string `json:"clips_tab_pinned_user_ids"` 109 | CommentInformTreatment InformTreatment `json:"comment_inform_treatment"` 110 | FundraiserTag FundraiserTag `json:"fundraiser_tag"` 111 | SharingFrictionInfo SharingFrictionInfo `json:"sharing_friction_info"` 112 | XPostDenyReason string `json:"xpost_deny_reason"` 113 | CaptionIsEdited bool `json:"caption_is_edited"` 114 | OriginalMediaHasVisualReplyMedia bool `json:"original_media_has_visual_reply_media"` 115 | LikeAndViewCountsDisabled bool `json:"like_and_view_counts_disabled"` 116 | FbUserTags FBUserTags `json:"fb_user_tags"` 117 | CanViewerSave bool `json:"can_viewer_save"` 118 | IsInProfileGrid bool `json:"is_in_profile_grid"` 119 | ProfileGridControlEnabled bool `json:"profile_grid_control_enabled"` 120 | FeaturedProducts []string `json:"featured_products"` 121 | IsCommentsGifComposerEnabled bool `json:"is_comments_gif_composer_enabled"` 122 | ProductSuggestions []string `json:"product_suggestions"` 123 | User User `json:"user"` 124 | ImageVersions2 ImageVersions2 `json:"image_versions2"` 125 | OriginalWidth int `json:"original_width"` 126 | OriginalHeight int `json:"original_height"` 127 | IsReshareOfTextPostAppMediaInIG bool `json:"is_reshare_of_text_post_app_media_in_ig"` 128 | CommentThreadingEnabled bool `json:"comment_threading_enabled"` 129 | MaxNumVisiblePreviewComments int `json:"max_num_visible_preview_comments"` 130 | HasMoreComments bool `json:"has_more_comments"` 131 | PreviewComments []Comment `json:"preview_comments"` 132 | CommentCount int `json:"comment_count"` 133 | CanViewMorePreviewComments bool `json:"can_view_more_preview_comments"` 134 | HideViewAllCommentEntrypoint bool `json:"hide_view_all_comment_entrypoint"` 135 | Likers []string `json:"likers"` 136 | ShopRoutingUserID interface{} `json:"shop_routing_user_id"` 137 | CanSeeInsightsAsBrand bool `json:"can_see_insights_as_brand"` 138 | IsOrganicProductTaggingEligible bool `json:"is_organic_product_tagging_eligible"` 139 | ProductType string `json:"product_type"` 140 | IsPaidPartnership bool `json:"is_paid_partnership"` 141 | MusicMetadata interface{} `json:"music_metadata"` 142 | DeletedReason int `json:"deleted_reason"` 143 | OrganicTrackingToken string `json:"organic_tracking_token"` 144 | TextPostAppInfo TextPostAppInfo `json:"text_post_app_info"` 145 | IntegrityReviewDecision string `json:"integrity_review_decision"` 146 | IgMediaSharingDisabled bool `json:"ig_media_sharing_disabled"` 147 | HasSharedToFB int `json:"has_shared_to_fb"` 148 | IsUnifiedVideo bool `json:"is_unified_video"` 149 | ShouldRequestAds bool `json:"should_request_ads"` 150 | IsVisualReplyCommenterNoticeEnabled bool `json:"is_visual_reply_commenter_notice_enabled"` 151 | CommercialityStatus string `json:"commerciality_status"` 152 | ExploreHideComments bool `json:"explore_hide_comments"` 153 | HasDelayedMetadata bool `json:"has_delayed_metadata"` 154 | } 155 | 156 | type Caption struct { 157 | Pk string `json:"pk"` 158 | UserID int64 `json:"user_id"` 159 | Text string `json:"text"` 160 | Type int `json:"type"` 161 | CreatedAt int64 `json:"created_at"` 162 | CreatedAtUTC int64 `json:"created_at_utc"` 163 | ContentType string `json:"content_type"` 164 | Status string `json:"status"` 165 | BitFlags int `json:"bit_flags"` 166 | DidReportAsSpam bool `json:"did_report_as_spam"` 167 | ShareEnabled bool `json:"share_enabled"` 168 | User User `json:"user"` 169 | IsCovered bool `json:"is_covered"` 170 | IsRankedComment bool `json:"is_ranked_comment"` 171 | MediaID int64 `json:"media_id"` 172 | PrivateReplyStatus int `json:"private_reply_status"` 173 | } 174 | 175 | type InformTreatment struct { 176 | ShouldHaveInformTreatment bool `json:"should_have_inform_treatment"` 177 | Text string `json:"text"` 178 | URL string `json:"url"` 179 | ActionType string `json:"action_type"` 180 | } 181 | 182 | type FundraiserTag struct { 183 | HasStandaloneFundraiser bool `json:"has_standalone_fundraiser"` 184 | } 185 | 186 | type SharingFrictionInfo struct { 187 | ShouldHaveSharingFriction bool `json:"should_have_sharing_friction"` 188 | BloksAppURL string `json:"bloks_app_url"` 189 | SharingFrictionPayload string `json:"sharing_friction_payload"` 190 | } 191 | 192 | type FBUserTags struct { 193 | In []string `json:"in"` 194 | } 195 | 196 | type ImageVersions2 struct { 197 | Candidates []struct { 198 | URL string `json:"url"` 199 | Width int `json:"width"` 200 | Height int `json:"height"` 201 | } `json:"candidates"` 202 | } 203 | 204 | type Comment struct { 205 | Pk int64 `json:"pk"` 206 | UserID int64 `json:"user_id"` 207 | Text string `json:"text"` 208 | Type int `json:"type"` 209 | CreatedAt int64 `json:"created_at"` 210 | CreatedAtUTC int64 `json:"created_at_utc"` 211 | ContentType string `json:"content_type"` 212 | Status string `json:"status"` 213 | BitFlags int `json:"bit_flags"` 214 | DidReportAsSpam bool `json:"did_report_as_spam"` 215 | ShareEnabled bool `json:"share_enabled"` 216 | User User `json:"user"` 217 | } 218 | 219 | type TextPostAppInfo struct { 220 | IsPostUnavailable bool `json:"is_post_unavailable"` 221 | IsReply bool `json:"is_reply"` 222 | ReplyToAuthor interface{} `json:"reply_to_author"` 223 | DirectReplyCount int `json:"direct_reply_count"` 224 | SelfThreadCount int `json:"self_thread_count"` 225 | ReplyFacepileUsers []User `json:"reply_facepile_users"` 226 | LinkPreviewAttachment interface{} `json:"link_preview_attachment"` 227 | CanReply bool `json:"can_reply"` 228 | ReplyControl string `json:"reply_control"` 229 | HushInfo interface{} `json:"hush_info"` 230 | ShareInfo ShareInfo `json:"share_info"` 231 | } 232 | 233 | type ShareInfo struct { 234 | CanRepost bool `json:"can_repost"` 235 | IsRepostedByViewer bool `json:"is_reposted_by_viewer"` 236 | CanQuotePost bool `json:"can_quote_post"` 237 | } 238 | 239 | type FanClubInfo struct { 240 | FanClubID int64 `json:"fan_club_id"` 241 | FanClubName string `json:"fan_club_name"` 242 | IsFanClubReferralEligible interface{} `json:"is_fan_club_referral_eligible"` 243 | FanConsiderationPageRevampEligiblity interface{} `json:"fan_consideration_page_revamp_eligiblity"` 244 | IsFanClubGiftingEligible interface{} `json:"is_fan_club_gifting_eligible"` 245 | SubscriberCount interface{} `json:"subscriber_count"` 246 | ConnectedMemberCount interface{} `json:"connected_member_count"` 247 | AutosaveToExclusiveHighlight interface{} `json:"autosave_to_exclusive_highlight"` 248 | HasEnoughSubscribersForSSC interface{} `json:"has_enough_subscribers_for_ssc"` 249 | } 250 | -------------------------------------------------------------------------------- /model/user.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | type UserProfile struct { 4 | HasAnonymousProfilePicture bool `json:"has_anonymous_profile_picture"` 5 | FollowerCount int `json:"follower_count"` 6 | MediaCount int `json:"media_count"` 7 | FollowingCount int `json:"following_count"` 8 | FollowingTagCount int `json:"following_tag_count"` 9 | FbidV2 string `json:"fbid_v2"` 10 | HasOnboardedToTextPostApp bool `json:"has_onboarded_to_text_post_app"` 11 | ShowTextPostAppBadge bool `json:"show_text_post_app_badge"` 12 | TextPostAppJoinerNumber int `json:"text_post_app_joiner_number"` 13 | ShowIGAppSwitcherBadge bool `json:"show_ig_app_switcher_badge"` 14 | Pk int `json:"pk"` 15 | PkID string `json:"pk_id"` 16 | Username string `json:"username"` 17 | FullName string `json:"full_name"` 18 | IsPrivate bool `json:"is_private"` 19 | IsVerified bool `json:"is_verified"` 20 | ProfilePicID string `json:"profile_pic_id"` 21 | ProfilePicURL string `json:"profile_pic_url"` 22 | HasOptEligibleShop bool `json:"has_opt_eligible_shop"` 23 | AccountBadges []string `json:"account_badges"` 24 | ThirdPartyDownloadsEnabled int `json:"third_party_downloads_enabled"` 25 | UnseenCount int `json:"unseen_count"` 26 | FriendshipStatus FriendStatus `json:"friendship_status"` 27 | LatestReelMedia int `json:"latest_reel_media"` 28 | ShouldShowCategory bool `json:"should_show_category"` 29 | } 30 | 31 | type FriendStatus struct { 32 | Following bool `json:"following"` 33 | IsPrivate bool `json:"is_private"` 34 | IncomingRequest bool `json:"incoming_request"` 35 | OutgoingRequest bool `json:"outgoing_request"` 36 | TextPostAppPreFollowing bool `json:"text_post_app_pre_following"` 37 | IsBestie bool `json:"is_bestie"` 38 | IsRestricted bool `json:"is_restricted"` 39 | IsFeedFavorite bool `json:"is_feed_favorite"` 40 | } 41 | 42 | type UserResponse struct { 43 | NumResults int `json:"num_results"` 44 | Users []UserProfile `json:"users"` 45 | HasMore bool `json:"has_more"` 46 | RankToken string `json:"rank_token"` 47 | Status string `json:"status"` 48 | } 49 | -------------------------------------------------------------------------------- /private/auth.go: -------------------------------------------------------------------------------- 1 | package private 2 | 3 | import ( 4 | "bytes" 5 | "encoding/base64" 6 | "encoding/json" 7 | "errors" 8 | "fmt" 9 | "io" 10 | "log" 11 | "net/http" 12 | "net/url" 13 | "strconv" 14 | "strings" 15 | 16 | "github.com/dwarvesf/go-threads/model" 17 | "github.com/dwarvesf/go-threads/util" 18 | "github.com/google/uuid" 19 | ) 20 | 21 | type publicKeyResponse struct { 22 | KeyID int 23 | Key []byte 24 | } 25 | 26 | func getInstagramPublicKey() (*publicKeyResponse, error) { 27 | parameters := struct { 28 | ID string `json:"id"` 29 | }{ 30 | ID: uuid.NewString(), 31 | } 32 | 33 | parametersAsBytes, err := json.Marshal(parameters) 34 | if err != nil { 35 | return nil, err 36 | } 37 | 38 | encodedParameters := url.QueryEscape(string(parametersAsBytes)) 39 | req, err := http.NewRequest("POST", InstagramAPI+"/qe/sync/", strings.NewReader(fmt.Sprintf("params=%s", encodedParameters))) 40 | if err != nil { 41 | return nil, err 42 | } 43 | 44 | req.Header.Add("User-Agent", "Barcelona 289.0.0.77.109 Android") 45 | req.Header.Add("Sec-Fetch-Site", "same-origin") 46 | req.Header.Add("Content-Type", "application/x-www-form-urlencoded; charset=UTF-8") 47 | 48 | client := &http.Client{} 49 | resp, err := client.Do(req) 50 | if err != nil { 51 | return nil, err 52 | } 53 | defer resp.Body.Close() 54 | 55 | publicKeyKeyID := resp.Header.Get("ig-set-password-encryption-key-id") 56 | publicKey := resp.Header.Get("ig-set-password-encryption-pub-key") 57 | 58 | publicKeyKeyIDAsInt, err := strconv.Atoi(publicKeyKeyID) 59 | if err != nil { 60 | return nil, err 61 | } 62 | rawDecodedText, err := base64.StdEncoding.DecodeString(publicKey) 63 | if err != nil { 64 | return nil, err 65 | } 66 | return &publicKeyResponse{publicKeyKeyIDAsInt, rawDecodedText}, nil 67 | } 68 | 69 | func Auth(username string, password string, deviceID string) (*model.AuthResponse, error) { 70 | pub, err := getInstagramPublicKey() 71 | if err != nil { 72 | return nil, err 73 | } 74 | encryptedPassword, timestamStr, _ := util.EncryptPassword(password, pub.KeyID, pub.Key) 75 | blockVersion := "5f56efad68e1edec7801f630b5c122704ec5378adbee6609a448f105f34a9c73" 76 | 77 | parameters := map[string]interface{}{ 78 | "client_input_params": map[string]interface{}{ 79 | "password": fmt.Sprintf("#PWD_INSTAGRAM:4:%s:%s", timestamStr, encryptedPassword), 80 | "contact_point": username, 81 | "device_id": deviceID, 82 | }, 83 | "server_params": map[string]interface{}{ 84 | "credential_type": "password", 85 | "device_id": deviceID, 86 | }, 87 | } 88 | 89 | parametersAsBytes, err := json.Marshal(parameters) 90 | if err != nil { 91 | return nil, fmt.Errorf("failed to marshal parameters to JSON: %v", err) 92 | } 93 | 94 | bkClientContext := map[string]interface{}{ 95 | "bloks_version": blockVersion, 96 | "styles_id": "instagram", 97 | } 98 | 99 | bkClientContextAsBytes, err := json.Marshal(bkClientContext) 100 | if err != nil { 101 | return nil, fmt.Errorf("failed to marshal bkClientContext to JSON: %v", err) 102 | } 103 | 104 | params := url.QueryEscape(string(parametersAsBytes)) 105 | bkClientContextStr := url.QueryEscape(string(bkClientContextAsBytes)) 106 | reqBody := fmt.Sprintf("params=%s&bk_client_context=%s&bloks_versioning_id=%s", params, bkClientContextStr, blockVersion) 107 | 108 | req, err := http.NewRequest("POST", fmt.Sprintf("%s/bloks/apps/com.bloks.www.bloks.caa.login.async.send_login_request/", InstagramAPI), bytes.NewBufferString(reqBody)) 109 | if err != nil { 110 | return nil, fmt.Errorf("failed to create HTTP request: %v", err) 111 | } 112 | 113 | req.Header.Set("Content-Type", "application/x-www-form-urlencoded; charset=UTF-8") 114 | req.Header.Set("User-Agent", "Barcelona 289.0.0.77.109 Android") 115 | req.Header.Set("Sec-Fetch-Site", "same-origin") 116 | 117 | client := http.Client{} 118 | resp, err := client.Do(req) 119 | if err != nil { 120 | return nil, fmt.Errorf("failed to send HTTP request: %v", err) 121 | } 122 | defer resp.Body.Close() 123 | 124 | respBody, err := io.ReadAll(resp.Body) 125 | if err != nil { 126 | log.Fatalln(err) 127 | } 128 | 129 | if resp.StatusCode != http.StatusOK { 130 | return nil, fmt.Errorf("request failed with status code: %d, response: %s", resp.StatusCode, string(respBody)) 131 | } 132 | 133 | raw := string(respBody) 134 | bearerKeyPosition := strings.Index(raw, "Bearer IGT:2:") 135 | if bearerKeyPosition < 0 { 136 | return nil, errors.New("unable to login") 137 | } 138 | key := raw[bearerKeyPosition:] 139 | backslashKeyPosition := strings.Index(key, "\\\\") 140 | 141 | token := key[13:backslashKeyPosition] 142 | 143 | return &model.AuthResponse{Token: token}, nil 144 | } 145 | -------------------------------------------------------------------------------- /private/constant.go: -------------------------------------------------------------------------------- 1 | package private 2 | 3 | // InstagramAPI endpoint to instagram API 4 | const InstagramAPI = "https://i.instagram.com/api/v1" 5 | -------------------------------------------------------------------------------- /private/create.go: -------------------------------------------------------------------------------- 1 | package private 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "fmt" 7 | "io" 8 | "net/http" 9 | "net/url" 10 | "time" 11 | 12 | "github.com/dwarvesf/go-threads/model" 13 | ) 14 | 15 | func (t PrivateAPI) CreatePost(content model.CreatePostRequest) (*model.CreatePostResponse, error) { 16 | currentTimestamp := time.Now().Unix() 17 | userID := t.UserID 18 | 19 | parameters := map[string]interface{}{ 20 | "publish_mode": "text_post", 21 | "text_post_app_info": `{"reply_control":0}`, 22 | "timezone_offset": t.TimezoneOffset, 23 | "source_type": "4", 24 | "caption": content.Caption, 25 | "_uid": userID, 26 | "device_id": t.DeviceID, 27 | "upload_id": currentTimestamp, 28 | "device": map[string]interface{}{ 29 | "manufacturer": t.DeviceManufacturer, 30 | "model": t.DeviceModel, 31 | "android_version": 25, 32 | "android_release": "7.1.1", 33 | }, 34 | } 35 | 36 | parametersJSON, err := json.Marshal(parameters) 37 | if err != nil { 38 | return nil, fmt.Errorf("failed to marshal parameters to JSON: %v", err) 39 | } 40 | 41 | encodedParameters := url.QueryEscape(string(parametersJSON)) 42 | 43 | reqBody := fmt.Sprintf("signed_body=SIGNATURE.%s", encodedParameters) 44 | 45 | req, err := http.NewRequest("POST", fmt.Sprintf("%s/media/configure_text_only_post/", InstagramAPI), bytes.NewBufferString(reqBody)) 46 | if err != nil { 47 | return nil, fmt.Errorf("failed to create HTTP request: %v", err) 48 | } 49 | 50 | req = updateRequestHeader(t.APIToken, req) 51 | 52 | client := http.Client{} 53 | resp, err := client.Do(req) 54 | if err != nil { 55 | return nil, fmt.Errorf("failed to send HTTP request: %v", err) 56 | } 57 | defer resp.Body.Close() 58 | 59 | respBody, err := io.ReadAll(resp.Body) 60 | if err != nil { 61 | return nil, fmt.Errorf("failed to read response body: %v", err) 62 | } 63 | 64 | if resp.StatusCode != http.StatusOK { 65 | return nil, fmt.Errorf("request failed with status code: %d, response: %s", resp.StatusCode, string(respBody)) 66 | } 67 | 68 | var createThreadResp model.CreatePostResponse 69 | err = json.Unmarshal(respBody, &createThreadResp) 70 | if err != nil { 71 | return nil, fmt.Errorf("failed to unmarshal response: %v", err) 72 | } 73 | return &createThreadResp, nil 74 | } 75 | -------------------------------------------------------------------------------- /private/follow.go: -------------------------------------------------------------------------------- 1 | package private 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "io" 7 | "net/http" 8 | 9 | "github.com/dwarvesf/go-threads/model" 10 | ) 11 | 12 | func (t PrivateAPI) FollowUser(id int) (*model.FollowUserResponse, error) { 13 | url := fmt.Sprintf("%s/friendships/create/%d/", InstagramAPI, id) 14 | req, err := http.NewRequest("POST", url, nil) 15 | if err != nil { 16 | return nil, err 17 | } 18 | 19 | req = updateRequestHeader(t.APIToken, req) 20 | 21 | client := &http.Client{} 22 | resp, err := client.Do(req) 23 | if err != nil { 24 | return nil, err 25 | } 26 | defer resp.Body.Close() 27 | 28 | body, err := io.ReadAll(resp.Body) 29 | if err != nil { 30 | return nil, err 31 | } 32 | 33 | var result model.FollowUserResponse 34 | err = json.Unmarshal(body, &result) 35 | if err != nil { 36 | return nil, err 37 | } 38 | 39 | return &result, nil 40 | } 41 | -------------------------------------------------------------------------------- /private/get_detail.go: -------------------------------------------------------------------------------- 1 | package private 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "io" 7 | "net/http" 8 | 9 | "github.com/dwarvesf/go-threads/model" 10 | ) 11 | 12 | func (t PrivateAPI) GetThreadByID(ID string) (*model.ThreadDetailResponse, error) { 13 | url := fmt.Sprintf("%s/text_feed/%s/replies", InstagramAPI, ID) 14 | req, err := http.NewRequest("GET", url, nil) 15 | if err != nil { 16 | return nil, fmt.Errorf("failed to create HTTP request: %v", err) 17 | } 18 | 19 | req = updateRequestHeader(t.APIToken, req) 20 | 21 | client := http.Client{} 22 | resp, err := client.Do(req) 23 | if err != nil { 24 | return nil, fmt.Errorf("failed to send HTTP request: %v", err) 25 | } 26 | defer resp.Body.Close() 27 | 28 | respBody, err := io.ReadAll(resp.Body) 29 | if err != nil { 30 | return nil, fmt.Errorf("failed to read response body: %v", err) 31 | } 32 | 33 | var thread model.ThreadDetailResponse 34 | err = json.Unmarshal(respBody, &thread) 35 | if err != nil { 36 | return nil, fmt.Errorf("failed to unmarshal response: %v", err) 37 | } 38 | 39 | return &thread, nil 40 | } 41 | -------------------------------------------------------------------------------- /private/get_follower.go: -------------------------------------------------------------------------------- 1 | package private 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "io" 7 | "net/http" 8 | 9 | "github.com/dwarvesf/go-threads/model" 10 | ) 11 | 12 | func (t PrivateAPI) GetUserFollowers(id int) (*model.UserFollowersResponse, error) { 13 | req, err := http.NewRequest("GET", fmt.Sprintf("%s/friendships/%d/followers/", InstagramAPI, id), nil) 14 | if err != nil { 15 | return nil, fmt.Errorf("failed to create HTTP request: %v", err) 16 | } 17 | 18 | req = updateRequestHeader(t.APIToken, req) 19 | 20 | client := http.Client{} 21 | resp, err := client.Do(req) 22 | if err != nil { 23 | return nil, fmt.Errorf("failed to send HTTP request: %v", err) 24 | } 25 | defer resp.Body.Close() 26 | 27 | respBody, err := io.ReadAll(resp.Body) 28 | if err != nil { 29 | return nil, fmt.Errorf("failed to read response body: %v", err) 30 | } 31 | 32 | if resp.StatusCode != http.StatusOK { 33 | return nil, fmt.Errorf("request failed with status code: %d, response: %s", resp.StatusCode, string(respBody)) 34 | } 35 | 36 | var followers model.UserFollowersResponse 37 | err = json.Unmarshal(respBody, &followers) 38 | if err != nil { 39 | return nil, fmt.Errorf("failed to unmarshal response: %v", err) 40 | } 41 | 42 | return &followers, nil 43 | } 44 | 45 | func (t PrivateAPI) GetFollowers() (*model.UserFollowersResponse, error) { 46 | return t.GetUserFollowers(t.UserID) 47 | } 48 | -------------------------------------------------------------------------------- /private/get_following.go: -------------------------------------------------------------------------------- 1 | package private 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "io" 7 | "net/http" 8 | 9 | "github.com/dwarvesf/go-threads/model" 10 | ) 11 | 12 | func (t PrivateAPI) GetUserFollowing(id int) (*model.UserFollowingResponse, error) { 13 | req, err := http.NewRequest("GET", fmt.Sprintf("%s/friendships/%d/following/", InstagramAPI, id), nil) 14 | if err != nil { 15 | return nil, fmt.Errorf("failed to create HTTP request: %v", err) 16 | } 17 | 18 | req = updateRequestHeader(t.APIToken, req) 19 | 20 | client := http.Client{} 21 | resp, err := client.Do(req) 22 | if err != nil { 23 | return nil, fmt.Errorf("failed to send HTTP request: %v", err) 24 | } 25 | defer resp.Body.Close() 26 | 27 | respBody, err := io.ReadAll(resp.Body) 28 | if err != nil { 29 | return nil, fmt.Errorf("failed to read response body: %v", err) 30 | } 31 | 32 | if resp.StatusCode != http.StatusOK { 33 | return nil, fmt.Errorf("request failed with status code: %d, response: %s", resp.StatusCode, string(respBody)) 34 | } 35 | 36 | var following model.UserFollowingResponse 37 | err = json.Unmarshal(respBody, &following) 38 | if err != nil { 39 | return nil, fmt.Errorf("failed to unmarshal response: %v", err) 40 | } 41 | 42 | return &following, nil 43 | } 44 | 45 | func (t PrivateAPI) GetFollowing() (*model.UserFollowingResponse, error) { 46 | return t.GetUserFollowing(t.UserID) 47 | } 48 | -------------------------------------------------------------------------------- /private/like.go: -------------------------------------------------------------------------------- 1 | package private 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "io" 7 | "net/http" 8 | ) 9 | 10 | func (t PrivateAPI) LikeThread(id string) (map[string]interface{}, error) { 11 | url := fmt.Sprintf("%s/media/%s_%d/like/", InstagramAPI, id, t.UserID) 12 | req, err := http.NewRequest("POST", url, nil) 13 | if err != nil { 14 | return nil, fmt.Errorf("failed to create HTTP request: %v", err) 15 | } 16 | 17 | req = updateRequestHeader(t.APIToken, req) 18 | 19 | client := http.Client{} 20 | resp, err := client.Do(req) 21 | if err != nil { 22 | return nil, fmt.Errorf("failed to send HTTP request: %v", err) 23 | } 24 | defer resp.Body.Close() 25 | 26 | respBody, err := io.ReadAll(resp.Body) 27 | if err != nil { 28 | return nil, fmt.Errorf("failed to read response body: %v", err) 29 | } 30 | 31 | var likingInfo map[string]interface{} 32 | err = json.Unmarshal(respBody, &likingInfo) 33 | if err != nil { 34 | return nil, fmt.Errorf("failed to unmarshal response: %v", err) 35 | } 36 | 37 | return likingInfo, nil 38 | } 39 | 40 | func (t PrivateAPI) UnLikeThread(id string) (map[string]interface{}, error) { 41 | url := fmt.Sprintf("%s/media/%s_%d/unlike/", InstagramAPI, id, t.UserID) 42 | req, err := http.NewRequest("POST", url, nil) 43 | if err != nil { 44 | return nil, fmt.Errorf("failed to create HTTP request: %v", err) 45 | } 46 | 47 | req = updateRequestHeader(t.APIToken, req) 48 | 49 | client := http.Client{} 50 | resp, err := client.Do(req) 51 | if err != nil { 52 | return nil, fmt.Errorf("failed to send HTTP request: %v", err) 53 | } 54 | defer resp.Body.Close() 55 | 56 | respBody, err := io.ReadAll(resp.Body) 57 | if err != nil { 58 | return nil, fmt.Errorf("failed to read response body: %v", err) 59 | } 60 | 61 | var likingInfo map[string]interface{} 62 | err = json.Unmarshal(respBody, &likingInfo) 63 | if err != nil { 64 | return nil, fmt.Errorf("failed to unmarshal response: %v", err) 65 | } 66 | 67 | return likingInfo, nil 68 | } 69 | -------------------------------------------------------------------------------- /private/search.go: -------------------------------------------------------------------------------- 1 | package private 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "io" 7 | "net/http" 8 | 9 | "github.com/dwarvesf/go-threads/model" 10 | ) 11 | 12 | func (t PrivateAPI) SearchUser(query string) (*model.UserResponse, error) { 13 | url := fmt.Sprintf("%s/users/search/?q=%s", InstagramAPI, query) 14 | req, err := http.NewRequest("GET", url, nil) 15 | if err != nil { 16 | return nil, fmt.Errorf("failed to create HTTP request: %v", err) 17 | } 18 | 19 | req = updateRequestHeader(t.APIToken, req) 20 | 21 | client := http.Client{} 22 | resp, err := client.Do(req) 23 | if err != nil { 24 | return nil, fmt.Errorf("failed to send HTTP request: %v", err) 25 | } 26 | defer resp.Body.Close() 27 | 28 | respBody, err := io.ReadAll(resp.Body) 29 | if err != nil { 30 | return nil, fmt.Errorf("failed to read response body: %v", err) 31 | } 32 | 33 | var users model.UserResponse 34 | err = json.Unmarshal(respBody, &users) 35 | if err != nil { 36 | return nil, fmt.Errorf("failed to unmarshal response: %v", err) 37 | } 38 | 39 | return &users, nil 40 | } 41 | -------------------------------------------------------------------------------- /private/service.go: -------------------------------------------------------------------------------- 1 | package private 2 | 3 | import ( 4 | "net/http" 5 | ) 6 | 7 | // Private impl 8 | type PrivateAPI struct { 9 | UserID int 10 | Username string 11 | Password string 12 | TimezoneOffset int 13 | DeviceID string 14 | APIToken string 15 | DeviceManufacturer string 16 | DeviceModel string 17 | DeviceOsVersion int 18 | DeviceOsRelease string 19 | } 20 | 21 | func generateAuthHeader(token string) map[string]string { 22 | return map[string]string{ 23 | "Authorization": "Bearer IGT:2:" + token, 24 | "User-Agent": "Barcelona 289.0.0.77.109 Android", 25 | "Sec-Fetch-Site": "same-origin", 26 | "Content-Type": "application/x-www-form-urlencoded; charset=UTF-8", 27 | } 28 | } 29 | 30 | func updateRequestHeader(token string, req *http.Request) *http.Request { 31 | headers := generateAuthHeader(token) 32 | for k, v := range headers { 33 | req.Header.Set(k, v) 34 | } 35 | return req 36 | } 37 | -------------------------------------------------------------------------------- /private/unfollow.go: -------------------------------------------------------------------------------- 1 | package private 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "io" 7 | "net/http" 8 | 9 | "github.com/dwarvesf/go-threads/model" 10 | ) 11 | 12 | func (t PrivateAPI) UnFollowUser(id int) (*model.FollowUserResponse, error) { 13 | url := fmt.Sprintf("%s/friendships/destroy/%d/", InstagramAPI, id) 14 | req, err := http.NewRequest("POST", url, nil) 15 | if err != nil { 16 | return nil, err 17 | } 18 | 19 | req = updateRequestHeader(t.APIToken, req) 20 | 21 | client := &http.Client{} 22 | resp, err := client.Do(req) 23 | if err != nil { 24 | return nil, err 25 | } 26 | defer resp.Body.Close() 27 | 28 | body, err := io.ReadAll(resp.Body) 29 | if err != nil { 30 | return nil, err 31 | } 32 | 33 | var result model.FollowUserResponse 34 | err = json.Unmarshal(body, &result) 35 | if err != nil { 36 | return nil, err 37 | } 38 | 39 | return &result, nil 40 | } 41 | -------------------------------------------------------------------------------- /public.go: -------------------------------------------------------------------------------- 1 | package threads 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | "net/http" 7 | "regexp" 8 | 9 | "sync" 10 | 11 | "github.com/dwarvesf/go-threads/model" 12 | "github.com/dwarvesf/go-threads/public" 13 | ) 14 | 15 | var instance PublicAPI 16 | var once sync.Once 17 | 18 | // NewThreads return the public api 19 | func NewThreads() PublicAPI { 20 | once.Do(func() { 21 | c, err := newPublicAPIClient() 22 | if err != nil { 23 | fmt.Println("unable to init public API: " + err.Error()) 24 | } 25 | instance = c 26 | }) 27 | return instance 28 | } 29 | 30 | func newPublicAPIClient() (*public.PublicAPI, error) { 31 | token, err := getThreadsAPIToken() 32 | if err != nil { 33 | return nil, err 34 | } 35 | 36 | return &public.PublicAPI{ 37 | APPToken: token, 38 | }, nil 39 | } 40 | 41 | // PublicAPI interface for Public API 42 | type PublicAPI interface { 43 | GetThreadLikers(ID int) (*model.LikersResponse, error) 44 | } 45 | 46 | func getThreadsAPIToken() (string, error) { 47 | url := "https://www.instagram.com/instagram" 48 | req, err := http.NewRequest("GET", url, nil) 49 | if err != nil { 50 | return "", err 51 | } 52 | for k, v := range fetchHTMLHeaders { 53 | req.Header.Add(k, v) 54 | } 55 | 56 | client := &http.Client{} 57 | resp, err := client.Do(req) 58 | if err != nil { 59 | return "", err 60 | } 61 | defer resp.Body.Close() 62 | 63 | body, err := io.ReadAll(resp.Body) 64 | if err != nil { 65 | return "", err 66 | } 67 | 68 | re := regexp.MustCompile(`LSD",\[\],{"token":"(.*?)"},\d+]`) 69 | match := re.FindStringSubmatch(string(body)) 70 | if len(match) < 2 { 71 | return "", fmt.Errorf("failed to extract token") 72 | } 73 | 74 | token := match[1] 75 | 76 | return token, nil 77 | } 78 | 79 | func GetThreadLikers(ID int) (*model.LikersResponse, error) { 80 | return NewThreads().GetThreadLikers(ID) 81 | } 82 | -------------------------------------------------------------------------------- /public/get_likers.go: -------------------------------------------------------------------------------- 1 | package public 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "fmt" 7 | "io" 8 | "net/http" 9 | "net/url" 10 | 11 | "github.com/dwarvesf/go-threads/model" 12 | ) 13 | 14 | func (s PublicAPI) GetThreadLikers(ID int) (*model.LikersResponse, error) { 15 | 16 | data := url.Values{ 17 | "lsd": []string{s.APPToken}, 18 | "variables": []string{fmt.Sprintf(`{"mediaID": %d}`, ID)}, 19 | "doc_id": []string{"9360915773983802"}, 20 | } 21 | 22 | req, err := http.NewRequest("POST", ThreadsAPIURL, bytes.NewBufferString(data.Encode())) 23 | if err != nil { 24 | return nil, err 25 | } 26 | req = prepareHeaders(req, s.APPToken) 27 | 28 | client := &http.Client{} 29 | resp, err := client.Do(req) 30 | if err != nil { 31 | return nil, err 32 | } 33 | defer resp.Body.Close() 34 | 35 | body, err := io.ReadAll(resp.Body) 36 | if err != nil { 37 | return nil, err 38 | } 39 | 40 | var result model.LikersResponse 41 | err = json.Unmarshal(body, &result) 42 | if err != nil { 43 | return nil, err 44 | } 45 | 46 | return &result, nil 47 | } 48 | -------------------------------------------------------------------------------- /public/service.go: -------------------------------------------------------------------------------- 1 | package public 2 | 3 | import "net/http" 4 | 5 | const ThreadsAPIURL = "https://www.threads.net/api/graphql" 6 | 7 | type PublicAPI struct { 8 | APPToken string 9 | } 10 | 11 | func prepareHeaders(req *http.Request, token string) *http.Request { 12 | for k, v := range defaultHeaders(token) { 13 | req.Header.Set(k, v) 14 | } 15 | 16 | return req 17 | } 18 | 19 | func defaultHeaders(token string) map[string]string { 20 | return map[string]string{ 21 | "Authority": "www.threads.net", 22 | "Accept": "*/*", 23 | "Accept-Language": "en-US,en;q=0.9", 24 | "Cache-Control": "no-cache", 25 | "Content-Type": "application/x-www-form-urlencoded", 26 | "Origin": "https://www.threads.net", 27 | "Pragma": "no-cache", 28 | "Sec-Fetch-Site": "same-origin", 29 | "X-ASBD-ID": "129477", 30 | "X-FB-LSD": token, 31 | "X-IG-App-ID": "238260118697367", 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /thread.go: -------------------------------------------------------------------------------- 1 | package threads 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | "net/http" 7 | "regexp" 8 | "strconv" 9 | 10 | "github.com/dwarvesf/go-threads/model" 11 | "github.com/dwarvesf/go-threads/private" 12 | ) 13 | 14 | // PrivateAPI interface for private API 15 | type PrivateAPI interface { 16 | CreatePost(content model.CreatePostRequest) (*model.CreatePostResponse, error) 17 | GetUserFollowers(id int) (*model.UserFollowersResponse, error) 18 | GetFollowers() (*model.UserFollowersResponse, error) 19 | GetFollowing() (*model.UserFollowingResponse, error) 20 | GetThreadByID(ID string) (*model.ThreadDetailResponse, error) 21 | SearchUser(query string) (*model.UserResponse, error) 22 | LikeThread(id string) (map[string]interface{}, error) 23 | UnLikeThread(id string) (map[string]interface{}, error) 24 | FollowUser(id int) (*model.FollowUserResponse, error) 25 | UnFollowUser(id int) (*model.FollowUserResponse, error) 26 | } 27 | 28 | // NewPrivateAPIClient new api client for private API 29 | func NewPrivateAPIClient(cfg *Config) (PrivateAPI, error) { 30 | if err := cfg.ReadyCheck(); err != nil { 31 | return nil, err 32 | } 33 | 34 | ins := &private.PrivateAPI{ 35 | UserID: cfg.UserID, 36 | Username: cfg.Username, 37 | Password: cfg.Password, 38 | TimezoneOffset: cfg.TimezoneOffset, 39 | DeviceID: cfg.DeviceID, 40 | DeviceManufacturer: cfg.DeviceManufacturer, 41 | DeviceModel: cfg.DeviceModel, 42 | DeviceOsVersion: cfg.DeviceOsVersion, 43 | DeviceOsRelease: cfg.DeviceOsRelease, 44 | APIToken: cfg.APIToken, 45 | } 46 | 47 | return ins, nil 48 | } 49 | 50 | // InitAPIClient new api client for private API 51 | func InitAPIClient(cfgFn ...ConfigFn) (PrivateAPI, error) { 52 | cfg, err := InitConfig(cfgFn...) 53 | if err != nil { 54 | return nil, err 55 | } 56 | if err := cfg.ReadyCheck(); err != nil { 57 | return nil, err 58 | } 59 | 60 | ins := &private.PrivateAPI{ 61 | UserID: cfg.UserID, 62 | Username: cfg.Username, 63 | Password: cfg.Password, 64 | TimezoneOffset: cfg.TimezoneOffset, 65 | DeviceID: cfg.DeviceID, 66 | DeviceManufacturer: cfg.DeviceManufacturer, 67 | DeviceModel: cfg.DeviceModel, 68 | DeviceOsVersion: cfg.DeviceOsVersion, 69 | DeviceOsRelease: cfg.DeviceOsRelease, 70 | APIToken: cfg.APIToken, 71 | } 72 | 73 | return ins, nil 74 | } 75 | 76 | var fetchHTMLHeaders = map[string]string{ 77 | "Authority": "www.threads.net", 78 | "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", 79 | "Accept-Language": "en-US,en;q=0.9", 80 | "Cache-Control": "no-cache", 81 | "Content-Type": "application/x-www-form-urlencoded", 82 | "Origin": "https://www.threads.net", 83 | "Pragma": "no-cache", 84 | "Referer": "https://www.instagram.com", 85 | "Sec-Fetch-Dest": "document", 86 | "Sec-Fetch-Mode": "navigate", 87 | "Sec-Fetch-Site": "cross-site", 88 | "Sec-Fetch-User": "?1", 89 | "Upgrade-Insecure-Requests": "1", 90 | } 91 | 92 | // GetUserByUsername get user by username via instagram api 93 | func GetUserByUsername(username string) (int, error) { 94 | client := &http.Client{} 95 | req, err := http.NewRequest("GET", fmt.Sprintf("https://www.instagram.com/%s", username), nil) 96 | if err != nil { 97 | return 0, fmt.Errorf("failed to create HTTP request: %v", err) 98 | } 99 | 100 | for key, value := range fetchHTMLHeaders { 101 | req.Header.Set(key, value) 102 | } 103 | 104 | resp, err := client.Do(req) 105 | if err != nil { 106 | return 0, fmt.Errorf("failed to send HTTP request: %v", err) 107 | } 108 | defer resp.Body.Close() 109 | 110 | respBody, err := io.ReadAll(resp.Body) 111 | if err != nil { 112 | return 0, fmt.Errorf("failed to read response body: %v", err) 113 | } 114 | 115 | userIDKeyPattern := regexp.MustCompile(`"user_id":"(\d+)",`) 116 | userIDKeyMatch := userIDKeyPattern.FindStringSubmatch(string(respBody)) 117 | if userIDKeyMatch == nil { 118 | return 0, fmt.Errorf("failed to find user ID in the response") 119 | } 120 | 121 | userID := userIDKeyMatch[1] 122 | 123 | return strconv.Atoi(userID) 124 | } 125 | -------------------------------------------------------------------------------- /util/util.go: -------------------------------------------------------------------------------- 1 | package util 2 | 3 | import ( 4 | "crypto/aes" 5 | "crypto/cipher" 6 | "crypto/rand" 7 | "crypto/rsa" 8 | "crypto/sha256" 9 | "crypto/x509" 10 | "encoding/base64" 11 | "encoding/hex" 12 | "encoding/pem" 13 | "errors" 14 | "fmt" 15 | "strconv" 16 | "time" 17 | ) 18 | 19 | const AndroidPrefix = "android" 20 | 21 | func GenerateAndroidDeviceID() string { 22 | timestamp := strconv.FormatInt(time.Now().UnixMicro(), 10) 23 | hash := sha256.Sum256([]byte(timestamp)) 24 | deviceID := hex.EncodeToString(hash[:16])[:16] 25 | return fmt.Sprintf("%s-%s", AndroidPrefix, deviceID) 26 | } 27 | 28 | func getRandomBytes(length int) ([]byte, error) { 29 | randomBytes := make([]byte, length) 30 | _, err := rand.Read(randomBytes) 31 | if err != nil { 32 | return nil, err 33 | } 34 | return randomBytes, nil 35 | } 36 | 37 | // EncryptPassword encrypt password for authentication 38 | func EncryptPassword(password string, pubKeyID int, pubKeyPem []byte) (string, string, error) { 39 | passwordAsBytes := []byte(password) 40 | 41 | currentTimestamp := time.Now().Unix() 42 | currentTimestampAsString := []byte(fmt.Sprintf("%d", currentTimestamp)) 43 | 44 | // Generate random secret key and initialization vector 45 | secretKey, err := getRandomBytes(32) 46 | if err != nil { 47 | return "", "", err 48 | } 49 | initializationVector, err := getRandomBytes(12) 50 | if err != nil { 51 | return "", "", err 52 | } 53 | 54 | block, _ := pem.Decode(pubKeyPem) 55 | if block == nil { 56 | return "", "", fmt.Errorf("failed to parse PEM block containing the public key") 57 | } 58 | instagramPublicKeyRaw, err := x509.ParsePKIXPublicKey(block.Bytes) 59 | if err != nil { 60 | return "", "", err 61 | } 62 | 63 | instagramPublicKey, ok := instagramPublicKeyRaw.(*rsa.PublicKey) 64 | if !ok { 65 | return "", "", errors.New("unknown type of public key") 66 | } 67 | 68 | // Encrypt secret key using Instagram public key 69 | encryptedSecretKey, err := rsa.EncryptPKCS1v15(rand.Reader, instagramPublicKey, secretKey) 70 | 71 | if err != nil { 72 | return "", "", err 73 | } 74 | 75 | // Encrypt password using secret key and initialization vector 76 | aesBlock, err := aes.NewCipher(secretKey) 77 | if err != nil { 78 | return "", "", err 79 | } 80 | aesGCM, err := cipher.NewGCM(aesBlock) 81 | if err != nil { 82 | return "", "", err 83 | } 84 | encryptedPassword := aesGCM.Seal(nil, initializationVector, passwordAsBytes, currentTimestampAsString) 85 | authTag := encryptedPassword[len(encryptedPassword)-aesGCM.Overhead():] 86 | encryptedPassword = encryptedPassword[:len(encryptedPassword)-aesGCM.Overhead()] 87 | 88 | // Construct the password encryption sequence 89 | passwordEncryptionSequence := make([]byte, 0) 90 | 91 | keyIDMixedBytes := []byte{ 92 | 1, byte(0xff & pubKeyID), 93 | } 94 | encryptedRSAKeyMixedBytes := []byte{0, 1} 95 | 96 | passwordEncryptionSequence = append(passwordEncryptionSequence, keyIDMixedBytes...) // Key ID 97 | passwordEncryptionSequence = append(passwordEncryptionSequence, initializationVector...) // Initialization Vector 98 | passwordEncryptionSequence = append(passwordEncryptionSequence, encryptedRSAKeyMixedBytes...) // Encrypted RSA key mixed bytes 99 | passwordEncryptionSequence = append(passwordEncryptionSequence, encryptedSecretKey...) // Encrypted secret key 100 | passwordEncryptionSequence = append(passwordEncryptionSequence, authTag...) // Encrypted tag 101 | passwordEncryptionSequence = append(passwordEncryptionSequence, encryptedPassword...) // Encrypted password 102 | 103 | passwordAsEncryptionSequenceAsBase64 := base64.StdEncoding.EncodeToString(passwordEncryptionSequence) 104 | 105 | return passwordAsEncryptionSequenceAsBase64, fmt.Sprintf("%d", currentTimestamp), nil 106 | } 107 | -------------------------------------------------------------------------------- /util/util_test.go: -------------------------------------------------------------------------------- 1 | package util 2 | 3 | import ( 4 | "encoding/hex" 5 | "strings" 6 | "testing" 7 | ) 8 | 9 | func TestGenerateAndroidDeviceID(t *testing.T) { 10 | t.Run("Generated device ID starts with 'android-'", func(t *testing.T) { 11 | deviceID := GenerateAndroidDeviceID() 12 | if !strings.HasPrefix(deviceID, "android-") { 13 | t.Errorf("Expected device ID to start with 'android-', got %s", deviceID) 14 | } 15 | }) 16 | 17 | t.Run("Generated device ID has length 24", func(t *testing.T) { 18 | deviceID := GenerateAndroidDeviceID() 19 | 20 | if len(deviceID) != 24 { 21 | t.Errorf("Expected device ID length to be 24, got %d", len(deviceID)) 22 | } 23 | }) 24 | 25 | t.Run("Generated device ID is a valid SHA-256 hash", func(t *testing.T) { 26 | deviceID := GenerateAndroidDeviceID() 27 | deviceID = strings.TrimPrefix(deviceID, "android-") 28 | _, err := hex.DecodeString(deviceID) 29 | if err != nil { 30 | t.Errorf("Expected device ID to be a valid SHA-256 hash, got %s", deviceID) 31 | } 32 | }) 33 | 34 | t.Run("Generated device IDs are unique", func(t *testing.T) { 35 | deviceID1 := GenerateAndroidDeviceID() 36 | deviceID2 := GenerateAndroidDeviceID() 37 | if deviceID1 == deviceID2 { 38 | t.Error("Expected generated device IDs to be unique") 39 | } 40 | }) 41 | } 42 | --------------------------------------------------------------------------------