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- MEDIA_FIELDS =
Everything the mirror stores about a post: engagement counts come straight off the media node (no insights permission needed for the account's own posts), thumbnail_url is the video poster frame, media_product_type tells FEED from REELS, and children are a carousel's slides.
"id,caption,media_type,media_url,thumbnail_url,permalink," \ "timestamp,like_count,comments_count,media_product_type," \ "children{id,media_type,media_url,thumbnail_url}".freeze
- STORY_FIELDS =
A story is a media node too, but the engagement fields don't exist on it.
"id,caption,media_type,media_url,thumbnail_url,permalink," \ "timestamp,media_product_type".freeze
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
-
#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.
-
#list_stories(ig_user_id: @ig_user_id, limit: 25) ⇒ Object
The account's LIVE stories — Meta returns only the ones still inside their 24 hours.
- #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:, quick_replies: nil) ⇒ 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.
175 176 177 178 |
# File 'lib/instagram_connect/client.rb', line 175 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.
264 265 266 267 268 269 270 |
# File 'lib/instagram_connect/client.rb', line 264 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
135 136 137 |
# File 'lib/instagram_connect/client.rb', line 135 def container_status(container_id:) get("/#{container_id}", { fields: "status_code,status" }) end |
#conversation_messages(conversation_id:, limit: 20) ⇒ Object
215 216 217 218 |
# File 'lib/instagram_connect/client.rb', line 215 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 ------------------------------------------------------
127 128 129 |
# File 'lib/instagram_connect/client.rb', line 127 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
117 118 119 |
# File 'lib/instagram_connect/client.rb', line 117 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.
245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 |
# File 'lib/instagram_connect/client.rb', line 245 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
272 273 274 275 276 277 278 279 |
# File 'lib/instagram_connect/client.rb', line 272 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.
230 231 232 233 234 235 236 237 238 |
# File 'lib/instagram_connect/client.rb', line 230 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
113 114 115 |
# File 'lib/instagram_connect/client.rb', line 113 def hide_comment(comment_id:, hidden: true) post("/#{comment_id}", { hide: hidden }) end |
#list_comments(media_id:, limit: 50) ⇒ Object
121 122 123 |
# File 'lib/instagram_connect/client.rb', line 121 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.
205 206 207 208 209 210 211 212 213 |
# File 'lib/instagram_connect/client.rb', line 205 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
157 158 159 160 |
# File 'lib/instagram_connect/client.rb', line 157 def list_media(ig_user_id: @ig_user_id, limit: 25) get("/#{require_ig_user_id(ig_user_id)}/media", { fields: MEDIA_FIELDS, 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.
186 187 188 |
# File 'lib/instagram_connect/client.rb', line 186 def list_pages get("/me/accounts", { fields: "id,name,access_token,instagram_business_account" }) end |
#list_stories(ig_user_id: @ig_user_id, limit: 25) ⇒ Object
The account's LIVE stories — Meta returns only the ones still inside their 24 hours. Expired stories simply stop appearing here.
164 165 166 167 |
# File 'lib/instagram_connect/client.rb', line 164 def list_stories(ig_user_id: @ig_user_id, limit: 25) get("/#{require_ig_user_id(ig_user_id)}/stories", { fields: STORY_FIELDS, limit: limit }) end |
#media_insights(media_id:, metrics: %w[reach likes comments])) ⇒ Object
169 170 171 |
# File 'lib/instagram_connect/client.rb', line 169 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.
224 225 226 |
# File 'lib/instagram_connect/client.rb', line 224 def page(page_id) get("/#{page_id}", { fields: "id,name,access_token,instagram_business_account" }) end |
#private_reply(comment_id:, text:, quick_replies: nil) ⇒ Object
One-time private reply to a comment (comment -> DM), valid 7 days. The one DM a comment entitles us to send. It rides the full messaging endpoint (recipient by comment_id), so it can carry quick replies — which is how a comment-triggered funnel opens with buttons.
95 96 97 98 99 100 101 102 103 104 105 |
# File 'lib/instagram_connect/client.rb', line 95 def private_reply(comment_id:, text:, quick_replies: nil) = { text: text } if quick_replies.present? [:quick_replies] = Array(quick_replies).map do |reply| { content_type: "text", title: reply[:title].to_s[0, 20], payload: reply[:payload].to_s } end end post("/me/messages", { recipient: { comment_id: comment_id }, message: }) end |
#profile(igsid:, fields: %w[name username profile_pic])) ⇒ Object
180 181 182 |
# File 'lib/instagram_connect/client.rb', line 180 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
131 132 133 |
# File 'lib/instagram_connect/client.rb', line 131 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
139 140 141 |
# File 'lib/instagram_connect/client.rb', line 139 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 --------------------------------------------------------
109 110 111 |
# File 'lib/instagram_connect/client.rb', line 109 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 |