Skip to content

Commit 96c064a

Browse files
authored
feat: API conformance audit — align client with official Threads API docs (#24)
2 parents 694a75f + 2048ac2 commit 96c064a

20 files changed

+1146
-151
lines changed

README.md

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,18 @@ err = client.ExchangeCodeForToken(ctx, "auth-code-from-callback")
6767
err = client.GetLongLivedToken(ctx) // Convert to long-lived token
6868
```
6969

70+
### App Access Tokens
71+
72+
For APIs that require app-level auth instead of user tokens (e.g., oEmbed):
73+
74+
```go
75+
// Full API call (server-side only — exposes app secret)
76+
tokenResp, err := client.GetAppAccessToken(ctx)
77+
78+
// Or use the shorthand format (no API call needed)
79+
shorthand := client.GetAppAccessTokenShorthand() // "TH|<APP_ID>|<APP_SECRET>"
80+
```
81+
7082
### Environment Variables
7183

7284
```bash
@@ -111,7 +123,7 @@ post, err := client.GetPost(ctx, threads.PostID("123"))
111123
posts, err := client.GetUserPosts(ctx, threads.UserID("456"), &threads.PaginationOptions{Limit: 25})
112124

113125
// Delete post
114-
err = client.DeletePost(ctx, threads.PostID("123"))
126+
deletedID, err := client.DeletePost(ctx, threads.PostID("123"))
115127
```
116128

117129
### Users & Profiles

auth.go

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,13 @@ type LongLivedTokenResponse struct {
3131
ExpiresIn int64 `json:"expires_in"`
3232
}
3333

34+
// AppAccessTokenResponse represents the response from the client_credentials token endpoint.
35+
// Unlike user token responses, app access tokens do not include expires_in or user_id fields.
36+
type AppAccessTokenResponse struct {
37+
AccessToken string `json:"access_token"`
38+
TokenType string `json:"token_type"`
39+
}
40+
3441
// generateState generates a random state parameter for OAuth security
3542
func generateState() (string, error) {
3643
b := make([]byte, 32)
@@ -469,6 +476,54 @@ func (c *Client) DebugToken(ctx context.Context, inputToken string) (*DebugToken
469476
return &debugResp, nil
470477
}
471478

479+
// GetAppAccessToken generates an app access token via the client_credentials OAuth flow.
480+
// Unlike user access tokens, the returned token is NOT stored in the client — app tokens
481+
// serve a different purpose and should be managed separately by the caller.
482+
// This requires ClientID and ClientSecret to be set in the client configuration.
483+
//
484+
// NOTE: The Threads API requires GET for this endpoint (not the POST + body
485+
// approach specified in RFC 6749 §4.4). As a result, client_secret appears in
486+
// the request URL. Only call this from a server-side environment.
487+
func (c *Client) GetAppAccessToken(ctx context.Context) (*AppAccessTokenResponse, error) {
488+
params := url.Values{
489+
"client_id": {c.config.ClientID},
490+
"client_secret": {c.config.ClientSecret},
491+
"grant_type": {"client_credentials"},
492+
}
493+
494+
resp, err := c.httpClient.GET("/oauth/access_token", params, "")
495+
if err != nil {
496+
return nil, NewNetworkError(0, "Failed to get app access token", err.Error(), true)
497+
}
498+
499+
if resp.StatusCode != http.StatusOK {
500+
return nil, c.handleTokenError(resp.StatusCode, resp.Body)
501+
}
502+
503+
var tokenResp AppAccessTokenResponse
504+
if err := json.Unmarshal(resp.Body, &tokenResp); err != nil {
505+
return nil, NewAPIError(resp.StatusCode, "Failed to parse app access token response", err.Error(), "")
506+
}
507+
508+
if c.config.Logger != nil {
509+
c.config.Logger.Info("Successfully obtained app access token",
510+
"token_type", tokenResp.TokenType)
511+
}
512+
513+
return &tokenResp, nil
514+
}
515+
516+
// GetAppAccessTokenShorthand returns the shorthand app token in the form TH|<APP_ID>|<APP_SECRET>.
517+
// This can be used directly as an access token for certain API operations without making an API call.
518+
// See the Threads API documentation for which endpoints accept this format.
519+
// Returns an empty string if ClientID or ClientSecret are not configured.
520+
func (c *Client) GetAppAccessTokenShorthand() string {
521+
if c.config.ClientID == "" || c.config.ClientSecret == "" {
522+
return ""
523+
}
524+
return "TH|" + c.config.ClientID + "|" + c.config.ClientSecret
525+
}
526+
472527
// SetTokenFromDebugInfo creates and sets token info from debug token response.
473528
// This method takes the response from the debug_token endpoint and creates a properly
474529
// configured TokenInfo struct with accurate expiration times based on the API response.

auth_test.go

Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -656,6 +656,138 @@ func TestSetTokenFromDebugInfo_WithLogger(t *testing.T) {
656656
}
657657
}
658658

659+
func TestGetAppAccessToken_Success(t *testing.T) {
660+
handler := func(w http.ResponseWriter, r *http.Request) {
661+
if r.Method != "GET" {
662+
http.NotFound(w, r)
663+
return
664+
}
665+
if r.URL.Path != "/oauth/access_token" {
666+
http.NotFound(w, r)
667+
return
668+
}
669+
q := r.URL.Query()
670+
if q.Get("grant_type") != "client_credentials" {
671+
w.WriteHeader(400)
672+
return
673+
}
674+
w.Header().Set("Content-Type", "application/json")
675+
w.WriteHeader(200)
676+
_, _ = w.Write([]byte(`{"access_token":"TH|test-client-id|some_token","token_type":"bearer"}`))
677+
}
678+
679+
server := httptest.NewServer(http.HandlerFunc(handler))
680+
t.Cleanup(server.Close)
681+
682+
config := &Config{
683+
ClientID: "test-client-id",
684+
ClientSecret: "test-client-secret",
685+
RedirectURI: "https://example.com/callback",
686+
}
687+
config.SetDefaults()
688+
config.BaseURL = server.URL
689+
690+
client, err := NewClient(config)
691+
if err != nil {
692+
t.Fatal(err)
693+
}
694+
695+
resp, err := client.GetAppAccessToken(context.Background())
696+
if err != nil {
697+
t.Fatalf("unexpected error: %v", err)
698+
}
699+
if resp.AccessToken != "TH|test-client-id|some_token" {
700+
t.Errorf("expected TH|test-client-id|some_token, got %s", resp.AccessToken)
701+
}
702+
if resp.TokenType != "bearer" {
703+
t.Errorf("expected bearer, got %s", resp.TokenType)
704+
}
705+
// App token must NOT be stored in client
706+
if client.GetAccessToken() != "" {
707+
t.Error("expected client access token to remain empty after GetAppAccessToken")
708+
}
709+
}
710+
711+
func TestGetAppAccessToken_ServerError(t *testing.T) {
712+
client := testClientNoAuth(t, jsonHandler(401, `{"error":{"message":"invalid credentials","type":"OAuthException","code":100}}`))
713+
_, err := client.GetAppAccessToken(context.Background())
714+
if err == nil {
715+
t.Fatal("expected error for server error response")
716+
}
717+
}
718+
719+
func TestGetAppAccessToken_ParseError(t *testing.T) {
720+
client := testClientNoAuth(t, jsonHandler(200, `not valid json`))
721+
_, err := client.GetAppAccessToken(context.Background())
722+
if err == nil {
723+
t.Fatal("expected error for invalid JSON response")
724+
}
725+
}
726+
727+
func TestGetAppAccessToken_WithLogger(t *testing.T) {
728+
handler := jsonHandler(200, `{"access_token":"TH|id|tok","token_type":"bearer"}`)
729+
config := testClientConfig(t, handler)
730+
config.Logger = &noopLogger{}
731+
client, err := NewClient(config)
732+
if err != nil {
733+
t.Fatal(err)
734+
}
735+
resp, err := client.GetAppAccessToken(context.Background())
736+
if err != nil {
737+
t.Fatalf("unexpected error: %v", err)
738+
}
739+
if resp.AccessToken != "TH|id|tok" {
740+
t.Errorf("expected TH|id|tok, got %s", resp.AccessToken)
741+
}
742+
}
743+
744+
func TestGetAppAccessTokenShorthand(t *testing.T) {
745+
config := &Config{
746+
ClientID: "my-app-123",
747+
ClientSecret: "my-secret-abc",
748+
RedirectURI: "https://example.com/callback",
749+
}
750+
config.SetDefaults()
751+
client, err := NewClient(config)
752+
if err != nil {
753+
t.Fatal(err)
754+
}
755+
756+
shorthand := client.GetAppAccessTokenShorthand()
757+
expected := "TH|my-app-123|my-secret-abc"
758+
if shorthand != expected {
759+
t.Errorf("expected %q, got %q", expected, shorthand)
760+
}
761+
}
762+
763+
func TestGetAppAccessTokenShorthand_Format(t *testing.T) {
764+
config := &Config{
765+
ClientID: "appid",
766+
ClientSecret: "appsecret",
767+
RedirectURI: "https://example.com/callback",
768+
}
769+
config.SetDefaults()
770+
client, err := NewClient(config)
771+
if err != nil {
772+
t.Fatal(err)
773+
}
774+
775+
shorthand := client.GetAppAccessTokenShorthand()
776+
if !strings.HasPrefix(shorthand, "TH|") {
777+
t.Errorf("expected shorthand to start with TH|, got %q", shorthand)
778+
}
779+
parts := strings.Split(shorthand, "|")
780+
if len(parts) != 3 {
781+
t.Errorf("expected 3 pipe-separated parts, got %d: %q", len(parts), shorthand)
782+
}
783+
if parts[1] != "appid" {
784+
t.Errorf("expected app ID appid in shorthand, got %q", parts[1])
785+
}
786+
if parts[2] != "appsecret" {
787+
t.Errorf("expected app secret appsecret in shorthand, got %q", parts[2])
788+
}
789+
}
790+
659791
func TestTokenExpiration(t *testing.T) {
660792
config := &Config{
661793
ClientID: "test-id",

constants.go

Lines changed: 39 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -25,11 +25,19 @@ const (
2525
// Reply processing
2626
ReplyPublishDelay = 10 * time.Second // Recommended delay before publishing reply
2727

28+
// Poll limits
29+
MinPollOptions = 2 // Minimum poll options (A and B required)
30+
MaxPollOptions = 4 // Maximum poll options
31+
MaxPollOptionLength = 25 // Maximum characters per poll option
32+
33+
// Alt text limits
34+
MaxAltTextLength = 1000 // Maximum characters for alt text
35+
2836
// Search constraints
2937
MinSearchTimestamp = 1688540400 // Minimum timestamp for search queries (July 5, 2023)
3038

3139
// Library version
32-
Version = "1.1.0"
40+
Version = "1.8.0"
3341

3442
// HTTP client defaults
3543
DefaultHTTPTimeout = 30 * time.Second // Default HTTP request timeout
@@ -44,7 +52,7 @@ const (
4452
// Field Sets for API requests
4553
const (
4654
// Post fields
47-
PostExtendedFields = "id,media_product_type,media_type,media_url,permalink,owner,username,text,timestamp,shortcode,thumbnail_url,children,is_quote_post,alt_text,link_attachment_url,has_replies,reply_audience,quoted_post,reposted_post,gif_url,is_verified,profile_picture_url"
55+
PostExtendedFields = "id,media_product_type,media_type,media_url,permalink,owner,username,text,timestamp,shortcode,thumbnail_url,children,is_quote_post,alt_text,link_attachment_url,has_replies,reply_audience,quoted_post,reposted_post,gif_url,is_verified,profile_picture_url,poll_attachment,topic_tag,is_spoiler_media,text_entities,text_attachment,location_id,location,allowlisted_country_codes,ghost_post_status,ghost_post_expiration_timestamp,is_reply,root_post,replied_to,is_reply_owned_by_me,hide_status,reply_approval_status"
4856

4957
// Ghost Post fields
5058
GhostPostFields = "id,media_product_type,media_type,media_url,permalink,owner,username,text,timestamp,shortcode,thumbnail_url,ghost_post_status,ghost_post_expiration_timestamp"
@@ -62,7 +70,7 @@ const (
6270
LocationFields = "id,address,name,city,country,latitude,longitude,postal_code"
6371

6472
// Publishing limit fields
65-
PublishingLimitFields = "quota_usage,config,reply_quota_usage,reply_config,delete_quota_usage,delete_config,location_search_quota_usage,location_search_config"
73+
PublishingLimitFields = "quota_usage,config,reply_quota_usage,reply_config,delete_quota_usage,delete_config,location_search_quota_usage,location_search_config,search_quota_usage,search_config"
6674
)
6775

6876
// Container Status values
@@ -84,6 +92,26 @@ const (
8492
MediaTypeImage = "IMAGE"
8593
MediaTypeVideo = "VIDEO"
8694
MediaTypeCarousel = "CAROUSEL"
95+
96+
// Response media types (returned by the API when retrieving posts)
97+
MediaTypeResponseText = "TEXT_POST"
98+
MediaTypeResponseCarousel = "CAROUSEL_ALBUM"
99+
MediaTypeAudio = "AUDIO"
100+
MediaTypeRepostFacade = "REPOST_FACADE"
101+
)
102+
103+
// Container error messages returned by the API
104+
const (
105+
ContainerErrFailedDownloadingVideo = "FAILED_DOWNLOADING_VIDEO"
106+
ContainerErrFailedProcessingAudio = "FAILED_PROCESSING_AUDIO"
107+
ContainerErrFailedProcessingVideo = "FAILED_PROCESSING_VIDEO"
108+
ContainerErrInvalidAspectRatio = "INVALID_ASPEC_RATIO" // Note: API has typo "ASPEC"
109+
ContainerErrInvalidBitRate = "INVALID_BIT_RATE"
110+
ContainerErrInvalidDuration = "INVALID_DURATION"
111+
ContainerErrInvalidFrameRate = "INVALID_FRAME_RATE"
112+
ContainerErrInvalidAudioChannels = "INVALID_AUDIO_CHANNELS"
113+
ContainerErrInvalidAudioChannelLayout = "INVALID_AUDIO_CHANNEL_LAYOUT"
114+
ContainerErrUnknown = "UNKNOWN"
87115
)
88116

89117
// Error messages
@@ -100,4 +128,12 @@ const (
100128
// This error occurs during media container creation (POST /{threads-user-id}/threads).
101129
// Reduce the number of unique links to 5 or fewer to resolve.
102130
ErrCodeLinkLimitExceeded = "THREADS_API__LINK_LIMIT_EXCEEDED"
131+
132+
// ErrCodeFeatureNotAvailable is returned when a feature is not available for the user.
133+
// For example, geo-gating requires feature approval before it can be used.
134+
ErrCodeFeatureNotAvailable = "THREADS_API__FEATURE_NOT_AVAILABLE"
135+
136+
// ErrCodeGeoGatingInvalidCountryCodes is returned when invalid country codes
137+
// are provided for geo-gated posts.
138+
ErrCodeGeoGatingInvalidCountryCodes = "THREADS_API__GEO_GATING_INVALID_COUNTRY_CODES"
103139
)

container_builder.go

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,9 +18,14 @@ func NewContainerBuilder() *ContainerBuilder {
1818
}
1919
}
2020

21-
// SetMediaType sets the media type
21+
// SetMediaType sets the media type.
22+
// If a non-TEXT type is set, any previously set is_ghost_post flag is cleared
23+
// since ghost posts are only supported for TEXT.
2224
func (b *ContainerBuilder) SetMediaType(mediaType string) *ContainerBuilder {
2325
b.params.Set("media_type", mediaType)
26+
if mediaType != "" && mediaType != MediaTypeText {
27+
b.params.Del("is_ghost_post")
28+
}
2429
return b
2530
}
2631

@@ -211,10 +216,21 @@ func (b *ContainerBuilder) SetGIFAttachment(gifAttachment *GIFAttachment) *Conta
211216
return b
212217
}
213218

214-
// SetIsGhostPost marks the post as a ghost post (text-only, expires in 24h, no replies allowed)
219+
// SetIsGhostPost marks the post as a ghost post (text-only, expires in 24h, no replies allowed).
220+
// Ghost posts are only supported for TEXT media type. If the builder's media_type is already set
221+
// to a non-TEXT value this call is silently ignored; if a non-TEXT type is set after this call,
222+
// SetMediaType will automatically clear the flag.
215223
func (b *ContainerBuilder) SetIsGhostPost(isGhostPost bool) *ContainerBuilder {
216224
if isGhostPost {
225+
// Ghost posts are only allowed for TEXT media type
226+
mediaType := b.params.Get("media_type")
227+
if mediaType != "" && mediaType != MediaTypeText {
228+
// Silently ignore - ghost post flag is not applicable to non-text posts
229+
return b
230+
}
217231
b.params.Set("is_ghost_post", "true")
232+
} else {
233+
b.params.Del("is_ghost_post")
218234
}
219235
return b
220236
}

0 commit comments

Comments
 (0)