threads-api 0.0.1.pre → 0.2.0

Sign up to get free protection for your applications and to get access to all the features.
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: