├── .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 |
7 |
8 |
9 |
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 |
--------------------------------------------------------------------------------