diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index daaca7d..94cfe35 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -54,7 +54,10 @@ jobs: fi - name: Run tests with coverage - run: go test -short -race -coverprofile=coverage.out ./... + # -coverpkg scopes coverage to the library package only, excluding + # examples/ and tests/integration/ which have no unit tests and + # would otherwise drag the reported percentage down. + run: go test -short -race -coverprofile=coverage.out -coverpkg=github.com/tirthpatell/threads-go ./... - name: Extract coverage percentage if: github.ref == 'refs/heads/main' && matrix.go-version == '1.24' diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index a5f5026..d3caa73 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -4,7 +4,7 @@ Thank you for contributing! This guide helps you get started with development. ## Prerequisites -- Go 1.21 or later +- Go 1.21 or later (tested on 1.21-1.24) - Git - Threads API credentials (for testing) @@ -25,7 +25,10 @@ export THREADS_REDIRECT_URI="your-redirect-uri" export THREADS_ACCESS_TOKEN="your-token" # Run tests -go test ./... +go test -short ./... +go test -short -race ./... + +# Run integration tests (requires credentials) go test ./tests/integration/... ``` diff --git a/LICENSE b/LICENSE index 9d7e4a4..35eb17c 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2024 Tirth Patel +Copyright (c) 2024-present Tirth Patel Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/README.md b/README.md index c683ca8..48255b7 100644 --- a/README.md +++ b/README.md @@ -5,14 +5,18 @@ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) [![Coverage](https://img.shields.io/endpoint?url=https://gist.githubusercontent.com/tirthpatell/2c608589294aed9aa900256daeec0fd4/raw/coverage.json)](https://github.com/tirthpatell/threads-go/actions) -Production-ready Go client for the Threads API with complete endpoint coverage, OAuth 2.0 authentication, rate limiting, and comprehensive error handling. +Unofficial, production-ready Go client for the [Threads API](https://developers.facebook.com/docs/threads) with complete endpoint coverage, OAuth 2.0 authentication, rate limiting, and comprehensive error handling. Not affiliated with or endorsed by Meta. + +Requires Go 1.21+. Tested on Go 1.21-1.24. ## Features -- Complete API coverage (posts, users, replies, insights, locations) +- Complete API coverage (posts, users, replies, insights, locations, search) +- GIF attachments (GIPHY), ghost posts, and reply approvals +- Fluent `ContainerBuilder` for advanced post creation - OAuth 2.0 flow and existing token support - Intelligent rate limiting with exponential backoff -- Type-safe error handling +- Type-safe error handling with transient error detection - Thread-safe concurrent usage - Comprehensive test coverage @@ -56,8 +60,9 @@ authURL := client.GetAuthURL(config.Scopes) // Redirect user to authURL // Exchange authorization code for token -err = client.ExchangeCodeForToken("auth-code-from-callback") -err = client.GetLongLivedToken() // Convert to long-lived token +ctx := context.Background() +err = client.ExchangeCodeForToken(ctx, "auth-code-from-callback") +err = client.GetLongLivedToken(ctx) // Convert to long-lived token ``` ### Environment Variables @@ -101,7 +106,7 @@ imagePost, err := client.CreateImagePost(ctx, &threads.ImagePostContent{ // Get posts post, err := client.GetPost(ctx, threads.PostID("123")) -posts, err := client.GetUserPosts(ctx, threads.UserID("456"), &threads.PostsOptions{Limit: 25}) +posts, err := client.GetUserPosts(ctx, threads.UserID("456"), &threads.PaginationOptions{Limit: 25}) // Delete post err = client.DeletePost(ctx, threads.PostID("123")) @@ -166,6 +171,56 @@ tagResults, err := client.KeywordSearch(ctx, "#technology", &threads.SearchOptio locations, err := client.SearchLocations(ctx, "New York", nil, nil) ``` +### GIF Posts + +```go +// Create a post with a GIF attachment (GIPHY) +gifPost, err := client.CreateTextPost(ctx, &threads.TextPostContent{ + Text: "Check out this GIF!", + GIFAttachment: &threads.GIFAttachment{ + GIFID: "giphy-gif-id", + Provider: threads.GIFProviderGiphy, + }, +}) +``` + +> Note: Tenor support is deprecated and will be sunset on March 31, 2026. Use GIPHY instead. + +### Ghost Posts + +```go +// Create a ghost post (auto-expiring) +ghost, err := client.CreateTextPost(ctx, &threads.TextPostContent{ + Text: "This will disappear!", + IsGhostPost: true, +}) + +// Retrieve a user's ghost posts +ghosts, err := client.GetUserGhostPosts(ctx, userID, nil) +``` + +### Reply Approvals + +```go +// Approve or ignore pending replies +err = client.ApprovePendingReply(ctx, threads.PostID("reply-id")) +err = client.IgnorePendingReply(ctx, threads.PostID("reply-id")) +``` + +### Container Builder + +For advanced post creation, use the fluent `ContainerBuilder`: + +```go +builder := threads.NewContainerBuilder(). + SetMediaType("TEXT"). + SetText("Hello from the builder!"). + SetReplyControl(threads.ReplyControlFollowersOnly). + SetTopicTag("golang"). + SetLocationID("location-id") +params := builder.Build() +``` + ### Pagination & Iterators For large datasets, use iterators to automatically handle pagination: @@ -231,6 +286,8 @@ case threads.IsRateLimitError(err): case threads.IsValidationError(err): validationErr := err.(*threads.ValidationError) log.Printf("Invalid %s: %s", validationErr.Field, err.Error()) +case threads.IsTransientError(err): + // Safe to retry — transient API error } ``` diff --git a/examples/.env.example b/examples/.env.example index 8767bdd..79e7a62 100644 --- a/examples/.env.example +++ b/examples/.env.example @@ -11,4 +11,4 @@ THREADS_CLIENT_SECRET=your_app_secret_here THREADS_REDIRECT_URI=https://your-domain.com/callback # Optional: Set to true to enable debug logging -DEBUG=false +THREADS_DEBUG=false diff --git a/examples/README.md b/examples/README.md index 98219fe..4955533 100644 --- a/examples/README.md +++ b/examples/README.md @@ -58,6 +58,8 @@ cd post-creation && go run main.go - Text, image, video posts - Carousel posts (multiple media) - Quote posts and reposts +- GIF posts (GIPHY) +- Ghost posts (auto-expiring) - Advanced options (reply controls, tags) ### Reply Management (`reply-management/`) @@ -70,6 +72,7 @@ cd reply-management && go run main.go - Create and retrieve replies - Conversation threading - Reply moderation (hide/unhide) +- Reply approvals (approve/ignore pending replies) - Pagination and sorting ### Insights & Analytics (`insights/`) diff --git a/examples/basic-usage/main.go b/examples/basic-usage/main.go index 9fae8d3..5d92202 100644 --- a/examples/basic-usage/main.go +++ b/examples/basic-usage/main.go @@ -112,7 +112,7 @@ post, err := client.CreateImagePost(ctx, &threads.ImagePostContent{ fmt.Println("Quote post:") fmt.Printf(` -post, err := client.CreateQuotePost(ctx, &threads.QuotePostContent{ +post, err := client.CreateTextPost(ctx, &threads.TextPostContent{ Text: "Adding my thoughts to this", QuotedPostID: "existing_post_id", }) diff --git a/examples/post-creation/main.go b/examples/post-creation/main.go index 9f9feef..124f6fc 100644 --- a/examples/post-creation/main.go +++ b/examples/post-creation/main.go @@ -83,9 +83,21 @@ func main() { createRepost(client) fmt.Println() - // Example 8: Error handling - fmt.Println("Example 8: Error Handling") - fmt.Println("========================") + // Example 8: GIF post + fmt.Println("Example 8: GIF Post") + fmt.Println("===================") + createGIFPost(client) + fmt.Println() + + // Example 9: Ghost post + fmt.Println("Example 9: Ghost Post") + fmt.Println("=====================") + createGhostPost(client) + fmt.Println() + + // Example 10: Error handling + fmt.Println("Example 10: Error Handling") + fmt.Println("=========================") demonstrateErrorHandling(client) fmt.Println() @@ -244,7 +256,7 @@ func createQuotePost(_ *threads.Client) { fmt.Printf(" Reply control: %s\n", content.ReplyControl) // Uncomment to actually create (requires real post ID): - // post, err := client.CreateQuotePost(content) + // post, err := client.CreateTextPost(ctx, content) // if err != nil { // fmt.Printf(" Failed to create quote post: %v\n", err) // handlePostError(err) @@ -267,7 +279,7 @@ func createRepost(_ *threads.Client) { fmt.Printf(" Post ID to repost: %s\n", postIDToRepost) // Uncomment to actually create (requires real post ID): - // post, err := client.RepostPost(postIDToRepost) + // post, err := client.RepostPost(ctx, threads.ConvertToPostID(postIDToRepost)) // if err != nil { // fmt.Printf(" Failed to create repost: %v\n", err) // handlePostError(err) @@ -279,6 +291,60 @@ func createRepost(_ *threads.Client) { fmt.Println(" Replace 'example_post_id_to_repost' with a real post ID to create reposts") } +func createGIFPost(client *threads.Client) { + ctx := context.Background() + content := &threads.TextPostContent{ + Text: "Check out this GIF!", + GIFAttachment: &threads.GIFAttachment{ + GIFID: "your-giphy-gif-id", + Provider: threads.GIFProviderGiphy, + }, + } + + fmt.Printf(" GIF post structure:\n") + fmt.Printf(" Text: %s\n", content.Text) + fmt.Printf(" GIF ID: %s\n", content.GIFAttachment.GIFID) + fmt.Printf(" Provider: %s\n", content.GIFAttachment.Provider) + + // Uncomment to actually create (requires real GIPHY GIF ID): + // post, err := client.CreateTextPost(ctx, content) + // if err != nil { + // fmt.Printf(" Failed to create GIF post: %v\n", err) + // handlePostError(err) + // return + // } + // fmt.Println(" GIF post created successfully!") + // printPostInfo(post) + _ = ctx + + fmt.Println(" Note: Tenor is deprecated (sunset March 31, 2026). Use GIFProviderGiphy instead.") +} + +func createGhostPost(client *threads.Client) { + ctx := context.Background() + content := &threads.TextPostContent{ + Text: "This is a ghost post -- it will auto-expire!", + IsGhostPost: true, + } + + fmt.Printf(" Ghost post structure:\n") + fmt.Printf(" Text: %s\n", content.Text) + fmt.Printf(" Is Ghost Post: %t\n", content.IsGhostPost) + + // Uncomment to actually create: + // post, err := client.CreateTextPost(ctx, content) + // if err != nil { + // fmt.Printf(" Failed to create ghost post: %v\n", err) + // handlePostError(err) + // return + // } + // fmt.Println(" Ghost post created successfully!") + // printPostInfo(post) + _ = ctx + + fmt.Println(" Ghost posts auto-expire and can be retrieved with client.GetUserGhostPosts()") +} + func demonstrateErrorHandling(client *threads.Client) { ctx := context.Background() fmt.Println("Testing various error scenarios...") diff --git a/examples/reply-management/main.go b/examples/reply-management/main.go index a0d914f..d56a40d 100644 --- a/examples/reply-management/main.go +++ b/examples/reply-management/main.go @@ -91,8 +91,14 @@ func main() { getUserReplyHistory(client, me.ID) fmt.Println() - // Example 7: Advanced reply options - fmt.Println("Example 7: Advanced Reply Features") + // Example 7: Reply approvals + fmt.Println("Example 7: Reply Approvals") + fmt.Println("==========================") + demonstrateReplyApprovals(client) + fmt.Println() + + // Example 8: Advanced reply options + fmt.Println("Example 8: Advanced Reply Features") fmt.Println("==================================") demonstrateAdvancedReplyFeatures(client, originalPost.ID) fmt.Println() @@ -366,6 +372,23 @@ func getUserReplyHistory(client *threads.Client, userID string) { } } +func demonstrateReplyApprovals(_ *threads.Client) { + fmt.Println(" Reply approvals allow you to review replies before they appear") + fmt.Println() + fmt.Println(" Usage:") + fmt.Println(" // Approve a pending reply") + fmt.Println(" err := client.ApprovePendingReply(ctx, threads.PostID(\"reply-id\"))") + fmt.Println() + fmt.Println(" // Ignore a pending reply") + fmt.Println(" err := client.IgnorePendingReply(ctx, threads.PostID(\"reply-id\"))") + fmt.Println() + fmt.Println(" // Enable reply approvals when creating a post") + fmt.Println(" content := &threads.TextPostContent{") + fmt.Println(" Text: \"Post with reply approvals\",") + fmt.Println(" EnableReplyApprovals: true,") + fmt.Println(" }") +} + func demonstrateAdvancedReplyFeatures(client *threads.Client, postID string) { fmt.Println(" Demonstrating advanced reply features...")