instagram_connect 0.1.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 +7 -0
- data/.github/workflows/ci.yml +41 -0
- data/.rspec +2 -0
- data/CHANGELOG.md +30 -0
- data/Gemfile +16 -0
- data/LICENSE.txt +21 -0
- data/MIT-LICENSE +20 -0
- data/README.md +145 -0
- data/Rakefile +7 -0
- data/app/controllers/instagram_connect/application_controller.rb +30 -0
- data/app/controllers/instagram_connect/comments_controller.rb +59 -0
- data/app/controllers/instagram_connect/conversations_controller.rb +17 -0
- data/app/controllers/instagram_connect/messages_controller.rb +19 -0
- data/app/controllers/instagram_connect/oauth_controller.rb +43 -0
- data/app/controllers/instagram_connect/posts_controller.rb +40 -0
- data/app/controllers/instagram_connect/webhooks_controller.rb +54 -0
- data/app/jobs/instagram_connect/application_job.rb +4 -0
- data/app/jobs/instagram_connect/ingest_job.rb +11 -0
- data/app/jobs/instagram_connect/refresh_tokens_job.rb +22 -0
- data/app/jobs/instagram_connect/send_message_job.rb +44 -0
- data/app/models/instagram_connect/account.rb +36 -0
- data/app/models/instagram_connect/application_record.rb +5 -0
- data/app/models/instagram_connect/comment.rb +26 -0
- data/app/models/instagram_connect/conversation.rb +43 -0
- data/app/models/instagram_connect/inbound_message.rb +15 -0
- data/app/models/instagram_connect/message.rb +37 -0
- data/app/views/instagram_connect/comments/_comment.html.erb +19 -0
- data/app/views/instagram_connect/comments/index.html.erb +11 -0
- data/app/views/instagram_connect/conversations/_composer.html.erb +15 -0
- data/app/views/instagram_connect/conversations/_conversation.html.erb +11 -0
- data/app/views/instagram_connect/conversations/_message.html.erb +6 -0
- data/app/views/instagram_connect/conversations/index.html.erb +11 -0
- data/app/views/instagram_connect/conversations/show.html.erb +12 -0
- data/app/views/instagram_connect/posts/index.html.erb +23 -0
- data/app/views/instagram_connect/posts/new.html.erb +17 -0
- data/app/views/layouts/instagram_connect/application.html.erb +19 -0
- data/bin/instagram_connect +6 -0
- data/config/routes.rb +28 -0
- data/docs/app_review_guide.md +57 -0
- data/docs/auth_paths.md +42 -0
- data/docs/roadmap.md +27 -0
- data/lib/generators/instagram_connect/controllers_generator.rb +16 -0
- data/lib/generators/instagram_connect/install/install_generator.rb +40 -0
- data/lib/generators/instagram_connect/install/templates/create_instagram_connect_accounts.rb.tt +16 -0
- data/lib/generators/instagram_connect/install/templates/create_instagram_connect_comments.rb.tt +17 -0
- data/lib/generators/instagram_connect/install/templates/create_instagram_connect_conversations.rb.tt +16 -0
- data/lib/generators/instagram_connect/install/templates/create_instagram_connect_inbound_messages.rb.tt +11 -0
- data/lib/generators/instagram_connect/install/templates/create_instagram_connect_messages.rb.tt +25 -0
- data/lib/generators/instagram_connect/install/templates/instagram_connect.rb.tt +35 -0
- data/lib/generators/instagram_connect/views_generator.rb +16 -0
- data/lib/instagram_connect/auth/facebook_login.rb +68 -0
- data/lib/instagram_connect/auth/instagram_login.rb +73 -0
- data/lib/instagram_connect/auth/strategy.rb +70 -0
- data/lib/instagram_connect/auth.rb +17 -0
- data/lib/instagram_connect/cli.rb +18 -0
- data/lib/instagram_connect/client.rb +160 -0
- data/lib/instagram_connect/configuration.rb +66 -0
- data/lib/instagram_connect/connect.rb +45 -0
- data/lib/instagram_connect/doctor.rb +22 -0
- data/lib/instagram_connect/engine.rb +28 -0
- data/lib/instagram_connect/errors.rb +25 -0
- data/lib/instagram_connect/ingest.rb +112 -0
- data/lib/instagram_connect/messaging_window.rb +42 -0
- data/lib/instagram_connect/railtie.rb +19 -0
- data/lib/instagram_connect/result.rb +34 -0
- data/lib/instagram_connect/signature_verifier.rb +23 -0
- data/lib/instagram_connect/version.rb +3 -0
- data/lib/instagram_connect.rb +53 -0
- metadata +160 -0
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
InstagramConnect.configure do |config|
|
|
2
|
+
# Which Meta login path to use:
|
|
3
|
+
# :instagram_login — graph.instagram.com, no Facebook Page required
|
|
4
|
+
# :facebook_login — graph.facebook.com, requires a linked Facebook Page
|
|
5
|
+
config.auth_path = :instagram_login
|
|
6
|
+
|
|
7
|
+
config.app_id = Rails.application.credentials.dig(:instagram_connect, :app_id) || ENV["INSTAGRAM_APP_ID"]
|
|
8
|
+
config.app_secret = Rails.application.credentials.dig(:instagram_connect, :app_secret) || ENV["INSTAGRAM_APP_SECRET"]
|
|
9
|
+
config.verify_token = Rails.application.credentials.dig(:instagram_connect, :verify_token) || ENV["INSTAGRAM_VERIFY_TOKEN"]
|
|
10
|
+
|
|
11
|
+
# Pin the Graph API version.
|
|
12
|
+
config.graph_version = "v21.0"
|
|
13
|
+
|
|
14
|
+
# Rails integration.
|
|
15
|
+
config.parent_controller = "ApplicationController"
|
|
16
|
+
|
|
17
|
+
# Require operators to be signed in to use the inbox (runs in controller
|
|
18
|
+
# context — call your host auth here):
|
|
19
|
+
# config.authenticate_with = -> { authenticate_user! }
|
|
20
|
+
|
|
21
|
+
# Attribute outbound replies to the current operator:
|
|
22
|
+
# config.current_user_id_resolver = -> { current_user&.id }
|
|
23
|
+
|
|
24
|
+
# Where the OAuth callback returns after connecting an account.
|
|
25
|
+
config.after_connect_redirect = "/instagram"
|
|
26
|
+
|
|
27
|
+
# Optional host hooks — react to inbound events (notifications, AI, …):
|
|
28
|
+
# config.on_message = ->(message) { }
|
|
29
|
+
# config.on_comment = ->(comment) { }
|
|
30
|
+
|
|
31
|
+
# Access tokens are encrypted at rest via Active Record Encryption. Run
|
|
32
|
+
# `bin/rails db:encryption:init` once, or set this to false if your app has
|
|
33
|
+
# no encryption configured (tokens will be stored in plain text).
|
|
34
|
+
# config.encrypt_tokens = false
|
|
35
|
+
end
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
require "rails/generators"
|
|
2
|
+
|
|
3
|
+
module InstagramConnect
|
|
4
|
+
module Generators
|
|
5
|
+
# `rails g instagram_connect:views` — copy the engine's views into the host
|
|
6
|
+
# app so they can be restyled to the app's own design system.
|
|
7
|
+
class ViewsGenerator < ::Rails::Generators::Base
|
|
8
|
+
source_root InstagramConnect::Engine.root.join("app/views").to_s
|
|
9
|
+
|
|
10
|
+
def copy_views
|
|
11
|
+
directory "instagram_connect", "app/views/instagram_connect"
|
|
12
|
+
directory "layouts/instagram_connect", "app/views/layouts/instagram_connect"
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
module InstagramConnect
|
|
2
|
+
module Auth
|
|
3
|
+
# "Instagram API with Facebook Login" — talks to graph.facebook.com and
|
|
4
|
+
# requires the Instagram professional account be linked to a Facebook Page
|
|
5
|
+
# inside Business Manager. Long-lived Page / System-User tokens are durable
|
|
6
|
+
# (effectively non-expiring), so refresh is a no-op.
|
|
7
|
+
class FacebookLogin < Strategy
|
|
8
|
+
GRAPH = "https://graph.facebook.com".freeze
|
|
9
|
+
DIALOG = "https://www.facebook.com".freeze
|
|
10
|
+
DEFAULT_SCOPES = %w[
|
|
11
|
+
instagram_basic
|
|
12
|
+
instagram_manage_messages
|
|
13
|
+
instagram_manage_comments
|
|
14
|
+
instagram_content_publish
|
|
15
|
+
pages_show_list
|
|
16
|
+
pages_manage_metadata
|
|
17
|
+
pages_read_engagement
|
|
18
|
+
].freeze
|
|
19
|
+
|
|
20
|
+
def graph_host
|
|
21
|
+
GRAPH
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def scopes
|
|
25
|
+
DEFAULT_SCOPES
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def authorize_url(redirect_uri:, state:)
|
|
29
|
+
query = {
|
|
30
|
+
client_id: app_id,
|
|
31
|
+
redirect_uri: redirect_uri,
|
|
32
|
+
scope: scopes.join(","),
|
|
33
|
+
response_type: "code",
|
|
34
|
+
state: state
|
|
35
|
+
}
|
|
36
|
+
"#{DIALOG}/#{graph_version}/dialog/oauth?#{URI.encode_www_form(query)}"
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def exchange_code(code:, redirect_uri:)
|
|
40
|
+
short = HTTParty.get("#{GRAPH}/#{graph_version}/oauth/access_token", timeout: HTTP_TIMEOUT, query: {
|
|
41
|
+
client_id: app_id,
|
|
42
|
+
client_secret: app_secret,
|
|
43
|
+
redirect_uri: redirect_uri,
|
|
44
|
+
code: code
|
|
45
|
+
})
|
|
46
|
+
raise ApiError.new("code exchange failed: HTTP #{short.code}", status: short.code) unless short.success?
|
|
47
|
+
short_data = JSON.parse(short.body)
|
|
48
|
+
|
|
49
|
+
long = HTTParty.get("#{GRAPH}/#{graph_version}/oauth/access_token", timeout: HTTP_TIMEOUT, query: {
|
|
50
|
+
grant_type: "fb_exchange_token",
|
|
51
|
+
client_id: app_id,
|
|
52
|
+
client_secret: app_secret,
|
|
53
|
+
fb_exchange_token: short_data["access_token"]
|
|
54
|
+
})
|
|
55
|
+
raise ApiError.new("long-lived exchange failed: HTTP #{long.code}", status: long.code) unless long.success?
|
|
56
|
+
long_data = JSON.parse(long.body)
|
|
57
|
+
|
|
58
|
+
{ access_token: long_data["access_token"], expires_at: expires_at_from(long_data["expires_in"]) }
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Long-lived Page / System-User tokens do not expire; return unchanged so
|
|
62
|
+
# the scheduled refresh job can treat every path uniformly.
|
|
63
|
+
def refresh_token(access_token:)
|
|
64
|
+
{ access_token: access_token, expires_at: nil }
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
module InstagramConnect
|
|
2
|
+
module Auth
|
|
3
|
+
# "Instagram API with Instagram Login" — talks to graph.instagram.com and
|
|
4
|
+
# needs NO linked Facebook Page. Tokens are 60-day, refreshable. This is
|
|
5
|
+
# Meta's recommended path for new integrations.
|
|
6
|
+
class InstagramLogin < Strategy
|
|
7
|
+
AUTHORIZE_URL = "https://www.instagram.com/oauth/authorize".freeze
|
|
8
|
+
TOKEN_URL = "https://api.instagram.com/oauth/access_token".freeze
|
|
9
|
+
GRAPH = "https://graph.instagram.com".freeze
|
|
10
|
+
DEFAULT_SCOPES = %w[
|
|
11
|
+
instagram_business_basic
|
|
12
|
+
instagram_business_manage_messages
|
|
13
|
+
instagram_business_manage_comments
|
|
14
|
+
instagram_business_content_publish
|
|
15
|
+
].freeze
|
|
16
|
+
|
|
17
|
+
def graph_host
|
|
18
|
+
GRAPH
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def scopes
|
|
22
|
+
DEFAULT_SCOPES
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def authorize_url(redirect_uri:, state:)
|
|
26
|
+
query = {
|
|
27
|
+
client_id: app_id,
|
|
28
|
+
redirect_uri: redirect_uri,
|
|
29
|
+
scope: scopes.join(","),
|
|
30
|
+
response_type: "code",
|
|
31
|
+
state: state
|
|
32
|
+
}
|
|
33
|
+
"#{AUTHORIZE_URL}?#{URI.encode_www_form(query)}"
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def exchange_code(code:, redirect_uri:)
|
|
37
|
+
short = HTTParty.post(TOKEN_URL, timeout: HTTP_TIMEOUT, body: {
|
|
38
|
+
client_id: app_id,
|
|
39
|
+
client_secret: app_secret,
|
|
40
|
+
grant_type: "authorization_code",
|
|
41
|
+
redirect_uri: redirect_uri,
|
|
42
|
+
code: code
|
|
43
|
+
})
|
|
44
|
+
raise ApiError.new("code exchange failed: HTTP #{short.code}", status: short.code) unless short.success?
|
|
45
|
+
short_data = JSON.parse(short.body)
|
|
46
|
+
|
|
47
|
+
long = HTTParty.get("#{GRAPH}/access_token", timeout: HTTP_TIMEOUT, query: {
|
|
48
|
+
grant_type: "ig_exchange_token",
|
|
49
|
+
client_secret: app_secret,
|
|
50
|
+
access_token: short_data["access_token"]
|
|
51
|
+
})
|
|
52
|
+
raise ApiError.new("long-lived exchange failed: HTTP #{long.code}", status: long.code) unless long.success?
|
|
53
|
+
long_data = JSON.parse(long.body)
|
|
54
|
+
|
|
55
|
+
{
|
|
56
|
+
access_token: long_data["access_token"],
|
|
57
|
+
expires_at: expires_at_from(long_data["expires_in"]),
|
|
58
|
+
ig_user_id: short_data["user_id"]&.to_s
|
|
59
|
+
}
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def refresh_token(access_token:)
|
|
63
|
+
resp = HTTParty.get("#{GRAPH}/refresh_access_token", timeout: HTTP_TIMEOUT, query: {
|
|
64
|
+
grant_type: "ig_refresh_token",
|
|
65
|
+
access_token: access_token
|
|
66
|
+
})
|
|
67
|
+
raise ApiError.new("token refresh failed: HTTP #{resp.code}", status: resp.code) unless resp.success?
|
|
68
|
+
data = JSON.parse(resp.body)
|
|
69
|
+
{ access_token: data["access_token"], expires_at: expires_at_from(data["expires_in"]) }
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
require "json"
|
|
2
|
+
require "uri"
|
|
3
|
+
require "httparty"
|
|
4
|
+
|
|
5
|
+
module InstagramConnect
|
|
6
|
+
module Auth
|
|
7
|
+
# Common interface for a Meta login path. Concrete strategies differ only in
|
|
8
|
+
# the Graph host, the OAuth dialog/exchange endpoints, and the scope set.
|
|
9
|
+
# Subclasses implement the public methods; the shared token-lifetime helper
|
|
10
|
+
# and credential accessors live here.
|
|
11
|
+
class Strategy
|
|
12
|
+
HTTP_TIMEOUT = 30
|
|
13
|
+
|
|
14
|
+
def initialize(config)
|
|
15
|
+
@config = config
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# e.g. "https://graph.instagram.com"
|
|
19
|
+
def graph_host
|
|
20
|
+
raise NotImplementedError
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# The scope set requested during OAuth.
|
|
24
|
+
def scopes
|
|
25
|
+
raise NotImplementedError
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# The "log in with…" dialog URL the host redirects the operator to.
|
|
29
|
+
def authorize_url(redirect_uri:, state:)
|
|
30
|
+
raise NotImplementedError
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Exchange an authorization code for a long-lived token. Returns a hash:
|
|
34
|
+
# { access_token:, expires_at:, ig_user_id: (optional), page_id: (optional) }
|
|
35
|
+
def exchange_code(code:, redirect_uri:)
|
|
36
|
+
raise NotImplementedError
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Refresh a long-lived token. Returns { access_token:, expires_at: }.
|
|
40
|
+
# Paths whose tokens don't expire return the token unchanged.
|
|
41
|
+
def refresh_token(access_token:)
|
|
42
|
+
raise NotImplementedError
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
private
|
|
46
|
+
|
|
47
|
+
attr_reader :config
|
|
48
|
+
|
|
49
|
+
def app_id
|
|
50
|
+
value = config.app_id
|
|
51
|
+
raise ConfigurationError, "app_id is not configured" if value.to_s.empty?
|
|
52
|
+
value
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def app_secret
|
|
56
|
+
value = config.app_secret
|
|
57
|
+
raise ConfigurationError, "app_secret is not configured" if value.to_s.empty?
|
|
58
|
+
value
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def graph_version
|
|
62
|
+
config.graph_version
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def expires_at_from(seconds)
|
|
66
|
+
seconds ? Time.now + seconds.to_i : nil
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
module InstagramConnect
|
|
2
|
+
# Selects the auth strategy for the configured path. New login paths register
|
|
3
|
+
# by adding a class to STRATEGIES — the same config-symbol dispatch used
|
|
4
|
+
# across the house gems.
|
|
5
|
+
module Auth
|
|
6
|
+
STRATEGIES = {
|
|
7
|
+
instagram_login: InstagramLogin,
|
|
8
|
+
facebook_login: FacebookLogin
|
|
9
|
+
}.freeze
|
|
10
|
+
|
|
11
|
+
def self.for(config)
|
|
12
|
+
klass = STRATEGIES[config.auth_path]
|
|
13
|
+
raise ConfigurationError, "unknown auth_path #{config.auth_path.inspect}" unless klass
|
|
14
|
+
klass.new(config)
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
require "thor"
|
|
2
|
+
require_relative "doctor"
|
|
3
|
+
|
|
4
|
+
module InstagramConnect
|
|
5
|
+
# Command-line entry point: `bundle exec instagram_connect doctor`.
|
|
6
|
+
class CLI < Thor
|
|
7
|
+
def self.exit_on_failure?
|
|
8
|
+
true
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
desc "doctor", "Check the instagram_connect configuration"
|
|
12
|
+
def doctor
|
|
13
|
+
Doctor.run.each do |check|
|
|
14
|
+
say(format("%-9s %s", check[:ok] ? "OK" : "MISSING", check[:label]))
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
require "httparty"
|
|
2
|
+
|
|
3
|
+
module InstagramConnect
|
|
4
|
+
# Thin wrapper over the Meta Graph API, bound to one account's access token.
|
|
5
|
+
# Every call returns a Result — API-level failures never raise, callers branch
|
|
6
|
+
# on success?. The Graph host + version come from the configured auth strategy.
|
|
7
|
+
class Client
|
|
8
|
+
TIMEOUT = 30
|
|
9
|
+
|
|
10
|
+
def initialize(access_token:, config: InstagramConnect.configuration, ig_user_id: nil)
|
|
11
|
+
@access_token = access_token
|
|
12
|
+
@config = config
|
|
13
|
+
@ig_user_id = ig_user_id
|
|
14
|
+
@strategy = Auth.for(config)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# --- Direct messages -------------------------------------------------
|
|
18
|
+
|
|
19
|
+
def send_text(recipient_id:, text:, tag: nil)
|
|
20
|
+
send_message(recipient: { id: recipient_id }, message: { text: text }, tag: tag)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def send_media(recipient_id:, url:, type: "image", tag: nil)
|
|
24
|
+
attachment = { type: type, payload: { url: url } }
|
|
25
|
+
send_message(recipient: { id: recipient_id }, message: { attachment: attachment }, tag: tag)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def send_reaction(recipient_id:, message_id:, reaction: "love")
|
|
29
|
+
post("/me/messages", {
|
|
30
|
+
recipient: { id: recipient_id },
|
|
31
|
+
sender_action: "react",
|
|
32
|
+
payload: { message_id: message_id, reaction: reaction }
|
|
33
|
+
})
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# One-time private reply to a comment (comment -> DM), valid 7 days.
|
|
37
|
+
def private_reply(comment_id:, text:)
|
|
38
|
+
post("/me/messages", { recipient: { comment_id: comment_id }, message: { text: text } })
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# --- Comments --------------------------------------------------------
|
|
42
|
+
|
|
43
|
+
def reply_comment(comment_id:, text:)
|
|
44
|
+
post("/#{comment_id}/replies", { message: text })
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def hide_comment(comment_id:, hidden: true)
|
|
48
|
+
post("/#{comment_id}", { hide: hidden })
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def delete_comment(comment_id:)
|
|
52
|
+
delete("/#{comment_id}")
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def list_comments(media_id:, limit: 50)
|
|
56
|
+
get("/#{media_id}/comments", { fields: "id,text,username,timestamp,parent_id", limit: limit })
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# --- Publishing ------------------------------------------------------
|
|
60
|
+
|
|
61
|
+
def create_media_container(ig_user_id: @ig_user_id, **params)
|
|
62
|
+
post("/#{require_ig_user_id(ig_user_id)}/media", params)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def publish_media(creation_id:, ig_user_id: @ig_user_id)
|
|
66
|
+
post("/#{require_ig_user_id(ig_user_id)}/media_publish", { creation_id: creation_id })
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def container_status(container_id:)
|
|
70
|
+
get("/#{container_id}", { fields: "status_code,status" })
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def publishing_limit(ig_user_id: @ig_user_id)
|
|
74
|
+
get("/#{require_ig_user_id(ig_user_id)}/content_publishing_limit", { fields: "quota_usage,config" })
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# --- Reads -----------------------------------------------------------
|
|
78
|
+
|
|
79
|
+
def list_media(ig_user_id: @ig_user_id, limit: 25)
|
|
80
|
+
get("/#{require_ig_user_id(ig_user_id)}/media",
|
|
81
|
+
{ fields: "id,caption,media_type,media_url,permalink,timestamp", limit: limit })
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def media_insights(media_id:, metrics: %w[reach likes comments])
|
|
85
|
+
get("/#{media_id}/insights", { metric: Array(metrics).join(",") })
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def profile(igsid:, fields: %w[name username profile_pic])
|
|
89
|
+
get("/#{igsid}", { fields: Array(fields).join(",") })
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# FB-Login only: the Pages this user administers, with their linked IG
|
|
93
|
+
# business account — used to resolve the account identity after OAuth.
|
|
94
|
+
def list_pages
|
|
95
|
+
get("/me/accounts", { fields: "id,name,access_token,instagram_business_account" })
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def fetch_media_binary(url:)
|
|
99
|
+
response = HTTParty.get(url, headers: bearer, timeout: TIMEOUT, follow_redirects: true)
|
|
100
|
+
unless response.success?
|
|
101
|
+
return Result.error("media fetch failed: HTTP #{response.code}", error_code: response.code)
|
|
102
|
+
end
|
|
103
|
+
body = response.body.to_s
|
|
104
|
+
Result.ok(data: { body: body, mime: response.headers["content-type"], size: body.bytesize })
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
private
|
|
108
|
+
|
|
109
|
+
attr_reader :access_token, :config, :strategy
|
|
110
|
+
|
|
111
|
+
def send_message(recipient:, message:, tag: nil)
|
|
112
|
+
body = { recipient: recipient, message: message }
|
|
113
|
+
if tag
|
|
114
|
+
body[:messaging_type] = "MESSAGE_TAG"
|
|
115
|
+
body[:tag] = tag
|
|
116
|
+
end
|
|
117
|
+
post("/me/messages", body)
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def require_ig_user_id(value)
|
|
121
|
+
value || raise(ConfigurationError, "ig_user_id is required for this call")
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def get(path, query = {})
|
|
125
|
+
parse(HTTParty.get(url(path), headers: bearer, query: query, timeout: TIMEOUT))
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def post(path, body = {})
|
|
129
|
+
parse(HTTParty.post(url(path),
|
|
130
|
+
headers: bearer.merge("Content-Type" => "application/json"),
|
|
131
|
+
body: body.to_json, timeout: TIMEOUT))
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def delete(path)
|
|
135
|
+
parse(HTTParty.delete(url(path), headers: bearer, timeout: TIMEOUT))
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def url(path)
|
|
139
|
+
"#{strategy.graph_host}/#{config.graph_version}#{path}"
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def bearer
|
|
143
|
+
{ "Authorization" => "Bearer #{access_token}" }
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def parse(response)
|
|
147
|
+
data = response.parsed_response
|
|
148
|
+
data = {} unless data.is_a?(Hash)
|
|
149
|
+
if response.success?
|
|
150
|
+
Result.ok(id: data["message_id"] || data["id"], data: data)
|
|
151
|
+
else
|
|
152
|
+
err = data["error"].is_a?(Hash) ? data["error"] : {}
|
|
153
|
+
Result.error(err["message"] || "HTTP #{response.code}",
|
|
154
|
+
error_code: err["code"],
|
|
155
|
+
retry_after: err.dig("error_data", "retry_after"),
|
|
156
|
+
data: data)
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
end
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
require "logger"
|
|
2
|
+
|
|
3
|
+
module InstagramConnect
|
|
4
|
+
# Host-facing configuration, set via InstagramConnect.configure { |c| ... }.
|
|
5
|
+
#
|
|
6
|
+
# +auth_path+ selects which Meta login flow + Graph host the gem talks to:
|
|
7
|
+
# :instagram_login -> graph.instagram.com, no Facebook Page required
|
|
8
|
+
# :facebook_login -> graph.facebook.com, requires a linked Facebook Page
|
|
9
|
+
#
|
|
10
|
+
# Secrets fall back to ENV so a host can configure entirely through the
|
|
11
|
+
# environment (e.g. container secrets) without an initializer edit.
|
|
12
|
+
class Configuration
|
|
13
|
+
AUTH_PATHS = %i[instagram_login facebook_login].freeze
|
|
14
|
+
|
|
15
|
+
attr_accessor :app_id, :app_secret, :verify_token,
|
|
16
|
+
:graph_version, :redirect_uri, :encrypt_tokens,
|
|
17
|
+
:parent_controller, :authenticate_with, :current_user_id_resolver,
|
|
18
|
+
:on_message, :on_comment, :on_postback,
|
|
19
|
+
:logger, :default_per_page, :inherit_host_layout,
|
|
20
|
+
:media_max_bytes, :allowed_media_types, :after_connect_redirect
|
|
21
|
+
attr_reader :auth_path
|
|
22
|
+
|
|
23
|
+
def initialize
|
|
24
|
+
@auth_path = (ENV["INSTAGRAM_CONNECT_AUTH_PATH"] || "instagram_login").to_sym
|
|
25
|
+
@app_id = ENV["INSTAGRAM_CONNECT_APP_ID"] || ENV["INSTAGRAM_APP_ID"]
|
|
26
|
+
@app_secret = ENV["INSTAGRAM_CONNECT_APP_SECRET"] || ENV["INSTAGRAM_APP_SECRET"]
|
|
27
|
+
@verify_token = ENV["INSTAGRAM_CONNECT_VERIFY_TOKEN"] || ENV["INSTAGRAM_VERIFY_TOKEN"]
|
|
28
|
+
@graph_version = ENV.fetch("INSTAGRAM_CONNECT_GRAPH_VERSION", "v21.0")
|
|
29
|
+
@redirect_uri = ENV["INSTAGRAM_CONNECT_REDIRECT_URI"]
|
|
30
|
+
@encrypt_tokens = true
|
|
31
|
+
@parent_controller = "::ApplicationController"
|
|
32
|
+
@authenticate_with = nil
|
|
33
|
+
@current_user_id_resolver = -> { respond_to?(:current_user) && current_user ? current_user.id : nil }
|
|
34
|
+
@on_message = nil
|
|
35
|
+
@on_comment = nil
|
|
36
|
+
@on_postback = nil
|
|
37
|
+
@logger = Logger.new($stdout)
|
|
38
|
+
@default_per_page = 25
|
|
39
|
+
@inherit_host_layout = true
|
|
40
|
+
# Where the OAuth callback redirects after connecting an account.
|
|
41
|
+
@after_connect_redirect = "/"
|
|
42
|
+
@media_max_bytes = 25 * 1024 * 1024
|
|
43
|
+
@allowed_media_types = %w[
|
|
44
|
+
image/jpeg image/png image/gif image/webp
|
|
45
|
+
video/mp4 audio/mpeg audio/aac application/pdf
|
|
46
|
+
].freeze
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Coerce to a symbol so hosts may pass a string ("facebook_login") from a
|
|
50
|
+
# config hash or ENV.
|
|
51
|
+
def auth_path=(value)
|
|
52
|
+
@auth_path = value&.to_sym
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Called by .configure at boot. Validates only structural config (a known
|
|
56
|
+
# auth_path) so an unconfigured host can still boot; secret presence is
|
|
57
|
+
# enforced lazily at the point of use (Client / OAuth).
|
|
58
|
+
def validate!
|
|
59
|
+
unless AUTH_PATHS.include?(auth_path)
|
|
60
|
+
raise ConfigurationError,
|
|
61
|
+
"auth_path must be one of #{AUTH_PATHS.join(', ')} (got #{auth_path.inspect})"
|
|
62
|
+
end
|
|
63
|
+
true
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
module InstagramConnect
|
|
2
|
+
# Turns an OAuth authorization code into a persisted Account. Extracted from
|
|
3
|
+
# the callback controller so the flow is unit-testable without the HTTP layer.
|
|
4
|
+
class Connect
|
|
5
|
+
def self.call(code:, redirect_uri:, config: InstagramConnect.configuration, connected_by_id: nil)
|
|
6
|
+
new(config).call(code: code, redirect_uri: redirect_uri, connected_by_id: connected_by_id)
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
def initialize(config)
|
|
10
|
+
@config = config
|
|
11
|
+
@strategy = Auth.for(config)
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def call(code:, redirect_uri:, connected_by_id: nil)
|
|
15
|
+
data = @strategy.exchange_code(code: code, redirect_uri: redirect_uri)
|
|
16
|
+
identity = resolve_identity(data)
|
|
17
|
+
|
|
18
|
+
account = Account.find_or_initialize_by(ig_user_id: identity[:ig_user_id])
|
|
19
|
+
account.assign_attributes(
|
|
20
|
+
auth_path: @config.auth_path.to_s,
|
|
21
|
+
access_token: data[:access_token],
|
|
22
|
+
token_expires_at: data[:expires_at],
|
|
23
|
+
page_id: identity[:page_id],
|
|
24
|
+
active: true
|
|
25
|
+
)
|
|
26
|
+
account.connected_by_id = connected_by_id unless connected_by_id.nil?
|
|
27
|
+
account.save!
|
|
28
|
+
account
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
private
|
|
32
|
+
|
|
33
|
+
# IG-Login returns the IG user id directly. FB-Login does not, so we look up
|
|
34
|
+
# the Page the operator administers and read its linked IG business account.
|
|
35
|
+
def resolve_identity(data)
|
|
36
|
+
return { ig_user_id: data[:ig_user_id], page_id: data[:page_id] } if data[:ig_user_id]
|
|
37
|
+
|
|
38
|
+
result = Client.new(access_token: data[:access_token], config: @config).list_pages
|
|
39
|
+
page = Array(result.data["data"]).find { |p| p["instagram_business_account"] }
|
|
40
|
+
raise ConfigurationError, "no Instagram business account is linked to a Page" unless page
|
|
41
|
+
|
|
42
|
+
{ ig_user_id: page.dig("instagram_business_account", "id"), page_id: page["id"] }
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
module InstagramConnect
|
|
2
|
+
# Preflight configuration checks, surfaced by the `instagram_connect doctor`
|
|
3
|
+
# CLI. Returns a list of { label:, ok: } so the CLI (or a host) can render it.
|
|
4
|
+
class Doctor
|
|
5
|
+
def self.run(config: InstagramConnect.configuration)
|
|
6
|
+
[
|
|
7
|
+
check("auth_path is valid", Configuration::AUTH_PATHS.include?(config.auth_path)),
|
|
8
|
+
check("app_id is set", present?(config.app_id)),
|
|
9
|
+
check("app_secret is set", present?(config.app_secret)),
|
|
10
|
+
check("verify_token is set", present?(config.verify_token))
|
|
11
|
+
]
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def self.check(label, ok)
|
|
15
|
+
{ label: label, ok: ok }
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def self.present?(value)
|
|
19
|
+
!value.to_s.empty?
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
require "rails/engine"
|
|
2
|
+
|
|
3
|
+
module InstagramConnect
|
|
4
|
+
# Mountable, namespace-isolated engine. Unlike whatsapp_notifier, the module
|
|
5
|
+
# name "InstagramConnect" is the Zeitwerk default camelization of
|
|
6
|
+
# "instagram_connect", so no inflector override is needed.
|
|
7
|
+
class Engine < ::Rails::Engine
|
|
8
|
+
isolate_namespace InstagramConnect
|
|
9
|
+
|
|
10
|
+
config.instagram_connect = {}
|
|
11
|
+
|
|
12
|
+
# Keep tokens and secrets out of logs.
|
|
13
|
+
initializer "instagram_connect.filter_sensitive_params" do |app|
|
|
14
|
+
app.config.filter_parameters += %i[access_token app_secret verify_token hub_verify_token]
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# Apply any host-provided config.instagram_connect = { ... } hash onto the
|
|
18
|
+
# gem Configuration via setters (in addition to the initializer DSL).
|
|
19
|
+
initializer "instagram_connect.configure" do |app|
|
|
20
|
+
InstagramConnect.configure do |config|
|
|
21
|
+
app.config.instagram_connect.each do |key, value|
|
|
22
|
+
setter = "#{key}="
|
|
23
|
+
config.public_send(setter, value) if config.respond_to?(setter)
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
module InstagramConnect
|
|
2
|
+
# Base class for every error raised by the gem.
|
|
3
|
+
class Error < StandardError; end
|
|
4
|
+
|
|
5
|
+
# Raised when the gem is misconfigured — an unknown auth_path, or a required
|
|
6
|
+
# secret (app_id / app_secret / verify_token) missing at the point of use.
|
|
7
|
+
class ConfigurationError < Error; end
|
|
8
|
+
|
|
9
|
+
# Raised when the Meta Graph API returns a non-success response. Carries the
|
|
10
|
+
# HTTP status, Meta's application-level error code, and any retry hint so
|
|
11
|
+
# callers (and the Result object) can react without re-parsing the body.
|
|
12
|
+
class ApiError < Error
|
|
13
|
+
attr_reader :status, :error_code, :retry_after
|
|
14
|
+
|
|
15
|
+
def initialize(message, status: nil, error_code: nil, retry_after: nil)
|
|
16
|
+
super(message)
|
|
17
|
+
@status = status
|
|
18
|
+
@error_code = error_code
|
|
19
|
+
@retry_after = retry_after
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# Raised when an inbound webhook fails HMAC signature verification.
|
|
24
|
+
class SignatureError < Error; end
|
|
25
|
+
end
|