Class: InstagramConnect::Client
- Inherits:
-
Object
- Object
- InstagramConnect::Client
- Defined in:
- lib/instagram_connect/client.rb
Overview
Thin wrapper over the Meta Graph API, bound to one account's access token. Every call returns a Result — API-level failures never raise, callers branch on success?. The Graph host + version come from the configured auth strategy.
Constant Summary collapse
- TIMEOUT =
30
Instance Method Summary collapse
-
#account_profile(ig_user_id: @ig_user_id, fields: %w[username name profile_picture_url followers_count media_count])) ⇒ Object
The connected account's own profile.
-
#collect(path, query = {}, limit_pages: 25) ⇒ Object
Every record across every page, for callers that just want the list.
- #container_status(container_id:) ⇒ Object
- #conversation_messages(conversation_id:, limit: 20) ⇒ Object
-
#create_media_container(ig_user_id: @ig_user_id, **params) ⇒ Object
--- Publishing ------------------------------------------------------.
- #delete_comment(comment_id:) ⇒ Object
- #delete_messenger_profile(fields:) ⇒ Object
-
#each_page(path, query = {}, limit_pages: 25) ⇒ Object
Walks a Graph collection to the end, following Meta's own cursors.
- #fetch_media_binary(url:) ⇒ Object
-
#granted_asset_ids ⇒ Object
The asset ids this token actually carries, from Meta's own record of the consent.
- #hide_comment(comment_id:, hidden: true) ⇒ Object
-
#initialize(access_token:, config: InstagramConnect.configuration, ig_user_id: nil) ⇒ Client
constructor
A new instance of Client.
- #list_comments(media_id:, limit: 50) ⇒ Object
-
#list_conversations(node_id: @ig_user_id, limit: 10) ⇒ Object
Threads that already exist on the account.
-
#list_media(ig_user_id: @ig_user_id, limit: 25) ⇒ Object
--- Reads -----------------------------------------------------------.
-
#list_pages ⇒ Object
FB-Login only: the Pages this user administers, with their linked IG business account — used to resolve the account identity after OAuth.
- #media_insights(media_id:, metrics: %w[reach likes comments])) ⇒ Object
- #messenger_profile(fields:) ⇒ Object
-
#page(page_id) ⇒ Object
One Page by id.
-
#private_reply(comment_id:, text:) ⇒ Object
One-time private reply to a comment (comment -> DM), valid 7 days.
- #profile(igsid:, fields: %w[name username profile_pic])) ⇒ Object
- #publish_media(creation_id:, ig_user_id: @ig_user_id) ⇒ Object
- #publishing_limit(ig_user_id: @ig_user_id) ⇒ Object
-
#reply_comment(comment_id:, text:) ⇒ Object
--- Comments --------------------------------------------------------.
- #send_attachment(recipient_id:, attachment_id:, tag: nil) ⇒ Object
- #send_media(recipient_id:, url:, type: "image", tag: nil) ⇒ Object
- #send_quick_replies(recipient_id:, text:, replies:, tag: nil) ⇒ Object
- #send_reaction(recipient_id:, message_id:, reaction: "love") ⇒ Object
-
#send_sender_action(recipient_id:, action:) ⇒ Object
Typing indicators and read receipts.
-
#send_text(recipient_id:, text:, tag: nil) ⇒ Object
--- Direct messages -------------------------------------------------.
-
#set_messenger_profile(**fields) ⇒ Object
Ice breakers and the persistent menu both live on the messenger profile.
-
#upload_attachment(file:, type: "image") ⇒ Object
Uploads bytes to Meta and returns a reusable attachment id.
Constructor Details
#initialize(access_token:, config: InstagramConnect.configuration, ig_user_id: nil) ⇒ Client
Returns a new instance of Client.
10 11 12 13 14 15 |
# File 'lib/instagram_connect/client.rb', line 10 def initialize(access_token:, config: InstagramConnect.configuration, ig_user_id: nil) @access_token = access_token @config = config @ig_user_id = ig_user_id @strategy = Auth.for(config) end |
Instance Method Details
#account_profile(ig_user_id: @ig_user_id, fields: %w[username name profile_picture_url followers_count media_count])) ⇒ Object
The connected account's own profile. A different call from #profile, which looks up the person on the other end of a thread.
145 146 147 148 |
# File 'lib/instagram_connect/client.rb', line 145 def account_profile(ig_user_id: @ig_user_id, fields: %w[username name profile_picture_url followers_count media_count]) get("/#{require_ig_user_id(ig_user_id)}", { fields: Array(fields).join(",") }) end |
#collect(path, query = {}, limit_pages: 25) ⇒ Object
Every record across every page, for callers that just want the list.
234 235 236 237 238 239 240 |
# File 'lib/instagram_connect/client.rb', line 234 def collect(path, query = {}, limit_pages: 25) records = [] result = each_page(path, query, limit_pages: limit_pages) { |page| records.concat(page.records) } return result if result.failure? Result.ok(data: { "data" => records }) end |
#container_status(container_id:) ⇒ Object
124 125 126 |
# File 'lib/instagram_connect/client.rb', line 124 def container_status(container_id:) get("/#{container_id}", { fields: "status_code,status" }) end |
#conversation_messages(conversation_id:, limit: 20) ⇒ Object
185 186 187 188 |
# File 'lib/instagram_connect/client.rb', line 185 def (conversation_id:, limit: 20) get("/#{conversation_id}", { fields: "messages.limit(#{limit}){id,created_time,from,to,message}" }) end |
#create_media_container(ig_user_id: @ig_user_id, **params) ⇒ Object
--- Publishing ------------------------------------------------------
116 117 118 |
# File 'lib/instagram_connect/client.rb', line 116 def create_media_container(ig_user_id: @ig_user_id, **params) post("/#{require_ig_user_id(ig_user_id)}/media", params) end |
#delete_comment(comment_id:) ⇒ Object
106 107 108 |
# File 'lib/instagram_connect/client.rb', line 106 def delete_comment(comment_id:) delete("/#{comment_id}") end |
#delete_messenger_profile(fields:) ⇒ Object
84 85 86 87 88 89 |
# File 'lib/instagram_connect/client.rb', line 84 def delete_messenger_profile(fields:) parse(HTTParty.delete(url("/me/messenger_profile"), headers: bearer.merge("Content-Type" => "application/json"), body: { fields: Array(fields), platform: "instagram" }.to_json, timeout: TIMEOUT)) end |
#each_page(path, query = {}, limit_pages: 25) ⇒ Object
Walks a Graph collection to the end, following Meta's own cursors.
Offsets are not safe here: a collection can shift between calls, so an
offset silently skips or repeats rows. limit_pages exists because these
collections can be unbounded and every page spends rate budget.
215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 |
# File 'lib/instagram_connect/client.rb', line 215 def each_page(path, query = {}, limit_pages: 25) cursor = nil pages = 0 while pages < limit_pages page_query = cursor ? query.merge(after: cursor) : query result = get(path, page_query) return result unless result.success? yield result pages += 1 cursor = result.next_cursor break if cursor.nil? end Result.ok(data: { "pages" => pages }) end |
#fetch_media_binary(url:) ⇒ Object
242 243 244 245 246 247 248 249 |
# File 'lib/instagram_connect/client.rb', line 242 def fetch_media_binary(url:) response = HTTParty.get(url, headers: bearer, timeout: TIMEOUT, follow_redirects: true) unless response.success? return Result.error("media fetch failed: HTTP #{response.code}", error_code: response.code) end body = response.body.to_s Result.ok(data: { body: body, mime: response.headers["content-type"], size: body.bytesize }) end |
#granted_asset_ids ⇒ Object
The asset ids this token actually carries, from Meta's own record of the consent. Needs no permission beyond the app's own credentials.
200 201 202 203 204 205 206 207 208 |
# File 'lib/instagram_connect/client.rb', line 200 def granted_asset_ids result = get("/debug_token", { input_token: access_token, access_token: "#{config.app_id}|#{config.app_secret}" }) return [] unless result.success? Array(result.data.dig("data", "granular_scopes")) .flat_map { |scope| Array(scope["target_ids"]) } .uniq end |
#hide_comment(comment_id:, hidden: true) ⇒ Object
102 103 104 |
# File 'lib/instagram_connect/client.rb', line 102 def hide_comment(comment_id:, hidden: true) post("/#{comment_id}", { hide: hidden }) end |
#list_comments(media_id:, limit: 50) ⇒ Object
110 111 112 |
# File 'lib/instagram_connect/client.rb', line 110 def list_comments(media_id:, limit: 50) get("/#{media_id}/comments", { fields: "id,text,username,timestamp,parent_id", limit: limit }) end |
#list_conversations(node_id: @ig_user_id, limit: 10) ⇒ Object
Threads that already exist on the account. Webhooks only ever tell us about NEW activity, so without this an inbox starts empty and stays empty until somebody happens to message in. Meta returns the 20 most recent messages per thread and no more — that is the whole history available to any app.
node_id is the object that OWNS the conversations edge, and on the Facebook-Login path that is the PAGE, not the Instagram user — verified in production, where the Instagram user id answered with a capability error while holding a perfectly good Page token. limit defaults LOW deliberately, and shrinks further on refusal. Meta rejected 50 outright on a live account with "Please reduce the amount of data you're asking for" — then rejected 20 as well on a Page with a deep Messenger history. The edge is priced per row, the cursor walk means a small page costs nothing but round trips, and updated_time is gone because nothing ever read it.
175 176 177 178 179 180 181 182 183 |
# File 'lib/instagram_connect/client.rb', line 175 def list_conversations(node_id: @ig_user_id, limit: 10) path = "/#{require_ig_user_id(node_id)}/conversations" result = collect(path, { platform: "instagram", fields: "id", limit: limit }) return result if result.success? || !reduce_data_error?(result) # One retry at the smallest useful page. If Meta refuses even this, the # caller's error handling reports Meta's own words as usual. collect(path, { platform: "instagram", fields: "id", limit: 2 }) end |
#list_media(ig_user_id: @ig_user_id, limit: 25) ⇒ Object
--- Reads -----------------------------------------------------------
134 135 136 137 |
# File 'lib/instagram_connect/client.rb', line 134 def list_media(ig_user_id: @ig_user_id, limit: 25) get("/#{require_ig_user_id(ig_user_id)}/media", { fields: "id,caption,media_type,media_url,permalink,timestamp", limit: limit }) end |
#list_pages ⇒ Object
FB-Login only: the Pages this user administers, with their linked IG business account — used to resolve the account identity after OAuth.
156 157 158 |
# File 'lib/instagram_connect/client.rb', line 156 def list_pages get("/me/accounts", { fields: "id,name,access_token,instagram_business_account" }) end |
#media_insights(media_id:, metrics: %w[reach likes comments])) ⇒ Object
139 140 141 |
# File 'lib/instagram_connect/client.rb', line 139 def media_insights(media_id:, metrics: %w[reach likes comments]) get("/#{media_id}/insights", { metric: Array(metrics).join(",") }) end |
#messenger_profile(fields:) ⇒ Object
80 81 82 |
# File 'lib/instagram_connect/client.rb', line 80 def messenger_profile(fields:) get("/me/messenger_profile", { fields: Array(fields).join(","), platform: "instagram" }) end |
#page(page_id) ⇒ Object
One Page by id. /me/accounts answers for the Pages a token may enumerate; this answers for a Page it may read. Those are not the same set — a Page granted through the Login-for-Business asset picker is readable here while absent from the listing.
194 195 196 |
# File 'lib/instagram_connect/client.rb', line 194 def page(page_id) get("/#{page_id}", { fields: "id,name,access_token,instagram_business_account" }) end |
#private_reply(comment_id:, text:) ⇒ Object
One-time private reply to a comment (comment -> DM), valid 7 days.
92 93 94 |
# File 'lib/instagram_connect/client.rb', line 92 def private_reply(comment_id:, text:) post("/me/messages", { recipient: { comment_id: comment_id }, message: { text: text } }) end |
#profile(igsid:, fields: %w[name username profile_pic])) ⇒ Object
150 151 152 |
# File 'lib/instagram_connect/client.rb', line 150 def profile(igsid:, fields: %w[name username profile_pic]) get("/#{igsid}", { fields: Array(fields).join(",") }) end |
#publish_media(creation_id:, ig_user_id: @ig_user_id) ⇒ Object
120 121 122 |
# File 'lib/instagram_connect/client.rb', line 120 def publish_media(creation_id:, ig_user_id: @ig_user_id) post("/#{require_ig_user_id(ig_user_id)}/media_publish", { creation_id: creation_id }) end |
#publishing_limit(ig_user_id: @ig_user_id) ⇒ Object
128 129 130 |
# File 'lib/instagram_connect/client.rb', line 128 def publishing_limit(ig_user_id: @ig_user_id) get("/#{require_ig_user_id(ig_user_id)}/content_publishing_limit", { fields: "quota_usage,config" }) end |
#reply_comment(comment_id:, text:) ⇒ Object
--- Comments --------------------------------------------------------
98 99 100 |
# File 'lib/instagram_connect/client.rb', line 98 def reply_comment(comment_id:, text:) post("/#{comment_id}/replies", { message: text }) end |
#send_attachment(recipient_id:, attachment_id:, tag: nil) ⇒ Object
51 52 53 54 |
# File 'lib/instagram_connect/client.rb', line 51 def (recipient_id:, attachment_id:, tag: nil) (recipient: { id: recipient_id }, message: { attachment: { payload: { attachment_id: } } }, tag: tag) end |
#send_media(recipient_id:, url:, type: "image", tag: nil) ⇒ Object
23 24 25 26 |
# File 'lib/instagram_connect/client.rb', line 23 def send_media(recipient_id:, url:, type: "image", tag: nil) = { type: type, payload: { url: url } } (recipient: { id: recipient_id }, message: { attachment: }, tag: tag) end |
#send_quick_replies(recipient_id:, text:, replies:, tag: nil) ⇒ Object
43 44 45 46 47 48 49 |
# File 'lib/instagram_connect/client.rb', line 43 def send_quick_replies(recipient_id:, text:, replies:, tag: nil) quick_replies = Array(replies).map do |reply| { content_type: "text", title: reply[:title].to_s[0, 20], payload: reply[:payload].to_s } end (recipient: { id: recipient_id }, message: { text: text, quick_replies: quick_replies }, tag: tag) end |
#send_reaction(recipient_id:, message_id:, reaction: "love") ⇒ Object
28 29 30 31 32 33 34 |
# File 'lib/instagram_connect/client.rb', line 28 def send_reaction(recipient_id:, message_id:, reaction: "love") post("/me/messages", { recipient: { id: recipient_id }, sender_action: "react", payload: { message_id: , reaction: reaction } }) end |
#send_sender_action(recipient_id:, action:) ⇒ Object
Typing indicators and read receipts. Meta is explicit that a sender action request must carry ONLY the recipient and the action — bundling it with a message silently drops one of the two.
39 40 41 |
# File 'lib/instagram_connect/client.rb', line 39 def send_sender_action(recipient_id:, action:) post("/me/messages", { recipient: { id: recipient_id }, sender_action: action }) end |
#send_text(recipient_id:, text:, tag: nil) ⇒ Object
--- Direct messages -------------------------------------------------
19 20 21 |
# File 'lib/instagram_connect/client.rb', line 19 def send_text(recipient_id:, text:, tag: nil) (recipient: { id: recipient_id }, message: { text: text }, tag: tag) end |
#set_messenger_profile(**fields) ⇒ Object
Ice breakers and the persistent menu both live on the messenger profile.
76 77 78 |
# File 'lib/instagram_connect/client.rb', line 76 def set_messenger_profile(**fields) post("/me/messenger_profile", fields.merge(platform: "instagram")) end |
#upload_attachment(file:, type: "image") ⇒ Object
Uploads bytes to Meta and returns a reusable attachment id.
Preferred over handing Meta a signed URL to the host's storage: a signed
URL is a bearer capability for a customer's file, sitting on the public
internet for as long as its TTL, fetched from an address we do not
control. This moves the bytes over an authenticated POST instead, and the
returned id can be reused rather than re-uploading the same file.
file must be a File or Tempfile — HTTParty builds the multipart body
from objects that expose a path, which is also what ActiveStorage's
blob.open yields.
66 67 68 69 70 71 72 73 |
# File 'lib/instagram_connect/client.rb', line 66 def (file:, type: "image") body = { platform: "instagram", message: { attachment: { type: type, payload: { is_reusable: true } } }.to_json, filedata: file } post_multipart("/me/message_attachments", body) end |