threads-api 0.0.1.pre → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 8ddb297052b28cab7f6a56c010ede66c4a1ef818a8d0862da7fb365856f482b0
4
- data.tar.gz: ed7000a484974ebb0dc2d66b87db2c59538f50f20616a54f64882f32c52523d5
3
+ metadata.gz: 422f4c1e70e0a2d5402d70646e6afd8fc095ff070b65e59f0acbf0afe4e66127
4
+ data.tar.gz: 421de26fc580b70c9bb76b4ef08adf040c97f94e92a7666acde6c42cbf9b1098
5
5
  SHA512:
6
- metadata.gz: d4f1504a2956ab19bd2133fc780cb6f679c0a2ec7ddc3c58ba75e2af99f3f0cf5899067f2909b58f1483cb4d55a8d094b089e5a1347140b6275518efdf0e575c
7
- data.tar.gz: 5e45860b0c861e477d5812fede1bdc4d1a0619cc30a0563442c6bd83001f73c7b29425f43665e8777f7439ffbb600fea6574a3893c54631a22050cdccd3cd81f
6
+ metadata.gz: 56d8bc23aa227e36406e1446159b13ea22317ea763dadb22aa25e07b17d7ae40eed568b16c614544035d33be72bb747af512a9ede63ad7fe4a03955fe7aab894
7
+ data.tar.gz: 6d1350e527061f18955d7cf4744a97e27ca5fcf65b3fc9515bdce8d7a18fe65e59f400a950e1023f251b295c8d2e68d986b6193e257a235e89d960c01c8316c9
data/README.md CHANGED
@@ -49,7 +49,126 @@ access_token = response.access_token
49
49
  expires_at = Time.now + response.expires_in
50
50
  ```
51
51
 
52
- Once you have a valid access token, whether it's short-lived or long-lived, you can use it to make requests to the Threads API.
52
+ Once you have a valid access token, whether it's short-lived or long-lived, you can use it to make requests to the Threads API using a `Threads::API::Client`:
53
+
54
+ ```ruby
55
+ client = Threads::API::Client.new(access_token)
56
+ ```
57
+
58
+ ## Reading Threads
59
+
60
+ To read threads for a user:
61
+
62
+ ```ruby
63
+ # List recent threads for a user.
64
+ response = client.list_threads # Defaults to the authenticated user
65
+ response = client.list_threads(user_id: "7770386109746442")
66
+
67
+ # By default, the Threads API returns 25 threads at a time. You can paginate through them like so:
68
+ next_page = client.list_threads(after: response.after_cursor) # or
69
+ previous_page = client.list_threads(before: response.before_cursor)
70
+
71
+ # Get a specific thread by ID.
72
+ thread = client.get_thread("18050206876707110") # Defaults to the authenticated user
73
+ thread = client.get_thread("18050206876707110", user_id: "7770386109746442")
74
+ ```
75
+
76
+ `Threads::API::Client#list_threads` accepts the following options:
77
+
78
+ * `user_id` - The ID of the user whose threads you want to read. Defaults to `"me"`, the authenticated user.
79
+ * `fields` - An Array (or comma-separated String) of fields to include in the response. By default, all documented fields are requested. See the [Threads API documentation](https://developers.facebook.com/docs/threads/threads-media#fields) for a list of available fields.
80
+ * `since` - An ISO 8601 date string. Only threads published after this date will be returned.
81
+ * `until` - An ISO 8601 date string. Only threads published before this date will be returned.
82
+ * `before` - A cursor string returned by a previous request for pagination.
83
+ * `after` - A cursor string returned by a previous request for pagination.
84
+ * `limit` - The number of threads to return. Defaults to `25`, with a maximum of `100`.
85
+
86
+ `Threads::API::Client#get_thread` accepts only the `user_id` and `fields` options.
87
+
88
+ ## Reading profiles
89
+
90
+ To get a user's profile:
91
+
92
+ ```ruby
93
+ profile = client.get_profile("7770386109746442")
94
+ ```
95
+
96
+ `Threads::API::Client#get_profile` accepts a `fields` option, which is an Array (or comma-separated String) of fields to include in the response. By default, all documented fields are requested. See the [Threads API documentation](https://developers.facebook.com/docs/threads/threads-profiles#fields) for a list of available fields.
97
+
98
+ ## Posting to Threads
99
+
100
+ Posting to Threads is, at the very least, a two-step process. Threads requires that you first create a container for the media you want to post, then explicitly publishing that container as a thread. However, more steps are involved if you want to post multiple media items in a single thread.
101
+
102
+ ### Creating the Thread
103
+
104
+ The first step in posting to Threads is to create a "media container", even if your post is text-only.
105
+
106
+ ```ruby
107
+ # Create a text-only post
108
+ client.create_thread(text: "Hello, world!")
109
+
110
+ # Create a post with a photo or video
111
+ client.create_thread(type: "IMAGE", image_url: "https://example.com/image.jpg", text: "Some optional text")
112
+ client.create_thread(type: "VIDEO", video_url: "https://example.com/video.mp4", text: "Some optional text")
113
+
114
+ # Reply to one of your own threads
115
+ client.create_thread(text: "Hello, world!", reply_to_id: "18050206876707110")
116
+
117
+ # Control who can reply to your thread. Defaults to "everyone".
118
+ client.create_thread(text: "Hello, world!", reply_control: "accounts_you_follow") # or "mentioned_only"
119
+ ```
120
+
121
+ ### Publishing the Thread
122
+
123
+ Once you've created a media container, you can publish it as a thread:
124
+
125
+ ```ruby
126
+ pending_thread = client.create_thread(text: "Hello, world!")
127
+ client.publish_thread(pending_thread.id)
128
+ ```
129
+
130
+ According to Meta, you may need to wait before attempting to publish a thread, especially if the thread contains images or videos. They suggest checking the status of the pending thread before attempting to publish it:
131
+
132
+ ```ruby
133
+ pending_thread = client.create_thread(text: "Hello, world!")
134
+ pending_thread = client.get_thread_status(pending_thread.id)
135
+
136
+ while pending_thread.in_progress?
137
+ # Wait a bit (they recommend checking only once per minute) and try again
138
+ sleep 60
139
+ pending_thread = client.get_thread_status(pending_thread.id)
140
+ end
141
+
142
+ if pending_thread.finished?
143
+ client.publish_thread(pending_thread.id)
144
+ elsif pending_thread.errored?
145
+ # Handle the error
146
+ else
147
+ # Unpublished threads expire after 24 hours.
148
+ pending_thread.expired?
149
+
150
+ # If you've already published the thread, the status will be "PUBLISHED".
151
+ pending_thread.published?
152
+ end
153
+ ```
154
+
155
+ ### Posting multiple photos and/or videos
156
+
157
+ Threads allows you to post a combination of up to 10 photos and/or videos in a single thread. To do so, you must first create a media container for each photo or video you want to post, then create a media container for the thread itself, and finally publish the thread.
158
+
159
+ ```ruby
160
+ # Create carousel items for each photo or video you want to post
161
+ image1 = client.create_carousel_item(type: "IMAGE", image_url: "https://example.com/image1.jpg")
162
+ image2 = client.create_carousel_item(type: "IMAGE", image_url: "https://example.com/image2.jpg")
163
+ video1 = client.create_carousel_item(type: "VIDEO", video_url: "https://example.com/video1.mp4")
164
+ video2 = client.create_carousel_item(type: "VIDEO", video_url: "https://example.com/video2.mp4")
165
+
166
+ # Create the media container for the thread itself
167
+ pending_thread = client.create_carousel_thread(text: "Some optional text", children: [image1.id, image2.id, video1.id, video2.id])
168
+
169
+ # Publish the thread
170
+ client.publish_thread(pending_thread.id)
171
+ ```
53
172
 
54
173
  ## Contributing
55
174
 
@@ -0,0 +1,142 @@
1
+ require_relative "profile"
2
+ require_relative "thread"
3
+
4
+ module Threads
5
+ module API
6
+ class Client
7
+ PROFILE_FIELDS = %w[id username threads_profile_picture_url threads_biography]
8
+ POST_FIELDS = %w[
9
+ id
10
+ media_product_type
11
+ media_type
12
+ media_url
13
+ permalink
14
+ owner
15
+ username
16
+ text
17
+ timestamp
18
+ shortcode
19
+ thumbnail_url
20
+ children
21
+ is_quote_post
22
+ ]
23
+
24
+ def initialize(access_token)
25
+ @access_token = access_token
26
+ end
27
+
28
+ def get_profile(user_id = "me", fields: PROFILE_FIELDS)
29
+ params = {access_token: @access_token}
30
+ params[:fields] = Array(fields).join(",") if fields
31
+
32
+ response = connection.get(user_id, params)
33
+
34
+ Threads::API::Profile.new(response.body)
35
+ end
36
+
37
+ def list_threads(user_id: "me", **options)
38
+ params = options.slice(:since, :until, :before, :after, :limit).compact
39
+ params[:access_token] = @access_token
40
+
41
+ fields = if options.key?(:fields)
42
+ Array(options[:fields]).join(",")
43
+ else
44
+ POST_FIELDS.join(",")
45
+ end
46
+
47
+ params[:fields] = fields unless fields.empty?
48
+
49
+ response = connection.get("#{user_id}/threads", params)
50
+
51
+ Threads::API::Thread::List.new(response.body)
52
+ end
53
+
54
+ def get_thread(thread_id, fields: POST_FIELDS)
55
+ params = {access_token: @access_token}
56
+ params[:fields] = Array(fields).join(",") if fields
57
+
58
+ response = connection.get(thread_id, params)
59
+
60
+ Threads::API::Thread.new(response.body)
61
+ end
62
+
63
+ def create_thread(type: "TEXT", text: nil, image_url: nil, video_url: nil, reply_to_id: nil, reply_control: nil)
64
+ params = {access_token: @access_token, media_type: type, text: text}
65
+ params[:reply_to_id] = reply_to_id if reply_to_id
66
+ params[:reply_control] = reply_control if reply_control
67
+
68
+ case type
69
+ when "IMAGE"
70
+ params[:image_url] = image_url || raise(ArgumentError, "The `:image_url` option is required when the post's type is \"IMAGE\"")
71
+ when "VIDEO"
72
+ params[:video_url] = video_url || raise(ArgumentError, "The `:video_url` option is required when the post's type is \"VIDEO\"")
73
+ when "TEXT"
74
+ raise ArgumentError, "The `:text` option is required when the post's type is \"TEXT\"" if text.nil? || text.empty?
75
+ else
76
+ raise ArgumentError, "Invalid post type: #{type}. Must be one of: \"TEXT\", \"IMAGE\", or \"VIDEO\""
77
+ end
78
+
79
+ response = connection.post("me/threads", params)
80
+
81
+ Threads::API::UnpublishedThread.new(response.body)
82
+ end
83
+
84
+ def get_thread_status(thread_id)
85
+ response = connection.get(thread_id, {
86
+ access_token: @access_token,
87
+ fields: "id,status,error_message"
88
+ })
89
+
90
+ Threads::API::ThreadStatus.new(response.body)
91
+ end
92
+
93
+ def create_carousel_item(type:, image_url: nil, video_url: nil)
94
+ params = {access_token: @access_token, media_type: type, is_carousel_item: true}
95
+
96
+ case type
97
+ when "IMAGE"
98
+ params[:image_url] = image_url || raise(ArgumentError, "The `:image_url` option is required when the item's type is \"IMAGE\"")
99
+ when "VIDEO"
100
+ params[:video_url] = video_url || raise(ArgumentError, "The `:video_url` option is required when the item's type is \"VIDEO\"")
101
+ else
102
+ raise ArgumentError, "Invalid item type: #{type}. Must be \"IMAGE\" or \"VIDEO\""
103
+ end
104
+
105
+ response = connection.post("me/threads", params)
106
+
107
+ Threads::API::UnpublishedThread.new(response.body)
108
+ end
109
+
110
+ def create_carousel_thread(children:, text: nil, reply_to_id: nil, reply_control: nil)
111
+ params = {access_token: @access_token, media_type: "CAROUSEL", text: text}
112
+ params[:children] = Array(children).join(",")
113
+ params[:reply_to_id] = reply_to_id if reply_to_id
114
+ params[:reply_control] = reply_control if reply_control
115
+
116
+ raise ArgumentError, "At least one item must be present in the `:children` option" if params[:children].empty?
117
+
118
+ response = connection.post("me/threads", params)
119
+
120
+ Threads::API::UnpublishedThread.new(response.body)
121
+ end
122
+
123
+ def publish_thread(id)
124
+ response = connection.post("me/threads_publish", {access_token: @access_token, creation_id: id})
125
+
126
+ Threads::API::ThreadStatus.new(response.body)
127
+ end
128
+ alias_method :publish_carousel, :publish_thread
129
+
130
+ private
131
+
132
+ def connection
133
+ @connection ||= Faraday.new(url: "https://graph.threads.net/v1.0/") do |f|
134
+ f.request :url_encoded
135
+
136
+ f.response :json
137
+ f.response :raise_error
138
+ end
139
+ end
140
+ end
141
+ end
142
+ end
@@ -2,7 +2,7 @@ module Threads
2
2
  module API
3
3
  module OAuth2
4
4
  class Client
5
- ShortLivedResponse = Struct.new(:access_token, :user_id)
5
+ ShortLivedResponse = Struct.new(:access_token, :user_id, :error_type, :error_message, :code)
6
6
  LongLivedResponse = Struct.new(:access_token, :token_type, :expires_in)
7
7
 
8
8
  def initialize(client_id:, client_secret:)
@@ -19,7 +19,7 @@ module Threads
19
19
  redirect_uri: redirect_uri
20
20
  })
21
21
 
22
- ShortLivedResponse.new(*response.body.values_at("access_token", "user_id"))
22
+ ShortLivedResponse.new(*response.body.values_at("access_token", "user_id", "error_type", "error_message", "code"))
23
23
  end
24
24
 
25
25
  def exchange_access_token(access_token)
@@ -0,0 +1,18 @@
1
+ require "time"
2
+
3
+ module Threads
4
+ module API
5
+ class Profile
6
+ attr_reader :id, :username, :profile_picture_url, :biography
7
+
8
+ def initialize(json)
9
+ @id = json["id"]
10
+ @username = json["username"]
11
+ @profile_picture_url = json["profile_picture_url"]
12
+ @biography = json["biography"]
13
+ end
14
+
15
+ alias_method :bio, :biography
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,82 @@
1
+ require "time"
2
+
3
+ module Threads
4
+ module API
5
+ class Thread
6
+ class List
7
+ attr_reader :threads, :before, :after
8
+
9
+ def initialize(json)
10
+ @threads = json["data"].map { |t| Threads::API::Thread.new(t) }
11
+
12
+ @before = json.dig("paging", "cursors", "before")
13
+ @after = json.dig("paging", "cursors", "after")
14
+ end
15
+ end
16
+
17
+ attr_reader :id, :type, :media_url, :permalink, :user_id, :username, :text, :timestamp, :created_at, :shortcode, :video_thumbnail_url, :children
18
+
19
+ def initialize(json)
20
+ @id = json["id"]
21
+ @type = json["media_type"]
22
+ @permalink = json["permalink"]
23
+ @shortcode = json["shortcode"]
24
+ @text = json["text"]
25
+ @media_url = json["media_url"]
26
+ @video_thumbnail_url = json["thumbnail_url"]
27
+ @user_id = json.dig("owner", "id")
28
+ @username = json["username"]
29
+ @is_quote_post = json["is_quote_post"]
30
+
31
+ @timestamp = json["timestamp"]
32
+ @created_at = Time.iso8601(@timestamp) if @timestamp
33
+
34
+ children = Array(json["children"])
35
+ @children = children.map { |c| Thread.new(c) } if children.any?
36
+ end
37
+
38
+ def quote_post?
39
+ @is_quote_post
40
+ end
41
+ end
42
+
43
+ class UnpublishedThread
44
+ attr_reader :id
45
+
46
+ def initialize(json)
47
+ @id = json["id"]
48
+ end
49
+ end
50
+
51
+ class ThreadStatus < UnpublishedThread
52
+ attr_reader :status, :error_message
53
+
54
+ def initialize(json)
55
+ super
56
+
57
+ @status = json["status"]
58
+ @error_message = json["error_message"]
59
+ end
60
+
61
+ def in_progress?
62
+ @status == "IN_PROGRESS"
63
+ end
64
+
65
+ def finished?
66
+ @status == "FINISHED"
67
+ end
68
+
69
+ def published?
70
+ @status == "PUBLISHED"
71
+ end
72
+
73
+ def errored?
74
+ @status == "ERROR"
75
+ end
76
+
77
+ def expired?
78
+ @status == "EXPIRED"
79
+ end
80
+ end
81
+ end
82
+ end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Threads
4
4
  module API
5
- VERSION = "0.0.1.pre"
5
+ VERSION = "0.2.0"
6
6
  end
7
7
  end
data/lib/threads/api.rb CHANGED
@@ -2,5 +2,6 @@
2
2
 
3
3
  require "faraday"
4
4
 
5
+ require_relative "api/client"
5
6
  require_relative "api/oauth2/client"
6
7
  require_relative "api/version"
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: threads-api
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.0.1.pre
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - David Celis
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2024-06-19 00:00:00.000000000 Z
11
+ date: 2024-07-04 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: faraday
@@ -37,7 +37,10 @@ files:
37
37
  - README.md
38
38
  - Rakefile
39
39
  - lib/threads/api.rb
40
+ - lib/threads/api/client.rb
40
41
  - lib/threads/api/oauth2/client.rb
42
+ - lib/threads/api/profile.rb
43
+ - lib/threads/api/thread.rb
41
44
  - lib/threads/api/version.rb
42
45
  homepage: https://github.com/davidcelis/threads-api
43
46
  licenses: