Class: InstagramConnect::Client

Inherits:
Object
  • Object
show all
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

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 (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_messages(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_idsObject

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_pagesObject

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 send_attachment(recipient_id:, attachment_id:, tag: nil)
  send_message(recipient: { id: recipient_id },
               message: { attachment: { payload: { attachment_id: 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)
  attachment = { type: type, payload: { url: url } }
  send_message(recipient: { id: recipient_id }, message: { attachment: 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
  send_message(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: 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)
  send_message(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 upload_attachment(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