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.
Files changed (69) hide show
  1. checksums.yaml +7 -0
  2. data/.github/workflows/ci.yml +41 -0
  3. data/.rspec +2 -0
  4. data/CHANGELOG.md +30 -0
  5. data/Gemfile +16 -0
  6. data/LICENSE.txt +21 -0
  7. data/MIT-LICENSE +20 -0
  8. data/README.md +145 -0
  9. data/Rakefile +7 -0
  10. data/app/controllers/instagram_connect/application_controller.rb +30 -0
  11. data/app/controllers/instagram_connect/comments_controller.rb +59 -0
  12. data/app/controllers/instagram_connect/conversations_controller.rb +17 -0
  13. data/app/controllers/instagram_connect/messages_controller.rb +19 -0
  14. data/app/controllers/instagram_connect/oauth_controller.rb +43 -0
  15. data/app/controllers/instagram_connect/posts_controller.rb +40 -0
  16. data/app/controllers/instagram_connect/webhooks_controller.rb +54 -0
  17. data/app/jobs/instagram_connect/application_job.rb +4 -0
  18. data/app/jobs/instagram_connect/ingest_job.rb +11 -0
  19. data/app/jobs/instagram_connect/refresh_tokens_job.rb +22 -0
  20. data/app/jobs/instagram_connect/send_message_job.rb +44 -0
  21. data/app/models/instagram_connect/account.rb +36 -0
  22. data/app/models/instagram_connect/application_record.rb +5 -0
  23. data/app/models/instagram_connect/comment.rb +26 -0
  24. data/app/models/instagram_connect/conversation.rb +43 -0
  25. data/app/models/instagram_connect/inbound_message.rb +15 -0
  26. data/app/models/instagram_connect/message.rb +37 -0
  27. data/app/views/instagram_connect/comments/_comment.html.erb +19 -0
  28. data/app/views/instagram_connect/comments/index.html.erb +11 -0
  29. data/app/views/instagram_connect/conversations/_composer.html.erb +15 -0
  30. data/app/views/instagram_connect/conversations/_conversation.html.erb +11 -0
  31. data/app/views/instagram_connect/conversations/_message.html.erb +6 -0
  32. data/app/views/instagram_connect/conversations/index.html.erb +11 -0
  33. data/app/views/instagram_connect/conversations/show.html.erb +12 -0
  34. data/app/views/instagram_connect/posts/index.html.erb +23 -0
  35. data/app/views/instagram_connect/posts/new.html.erb +17 -0
  36. data/app/views/layouts/instagram_connect/application.html.erb +19 -0
  37. data/bin/instagram_connect +6 -0
  38. data/config/routes.rb +28 -0
  39. data/docs/app_review_guide.md +57 -0
  40. data/docs/auth_paths.md +42 -0
  41. data/docs/roadmap.md +27 -0
  42. data/lib/generators/instagram_connect/controllers_generator.rb +16 -0
  43. data/lib/generators/instagram_connect/install/install_generator.rb +40 -0
  44. data/lib/generators/instagram_connect/install/templates/create_instagram_connect_accounts.rb.tt +16 -0
  45. data/lib/generators/instagram_connect/install/templates/create_instagram_connect_comments.rb.tt +17 -0
  46. data/lib/generators/instagram_connect/install/templates/create_instagram_connect_conversations.rb.tt +16 -0
  47. data/lib/generators/instagram_connect/install/templates/create_instagram_connect_inbound_messages.rb.tt +11 -0
  48. data/lib/generators/instagram_connect/install/templates/create_instagram_connect_messages.rb.tt +25 -0
  49. data/lib/generators/instagram_connect/install/templates/instagram_connect.rb.tt +35 -0
  50. data/lib/generators/instagram_connect/views_generator.rb +16 -0
  51. data/lib/instagram_connect/auth/facebook_login.rb +68 -0
  52. data/lib/instagram_connect/auth/instagram_login.rb +73 -0
  53. data/lib/instagram_connect/auth/strategy.rb +70 -0
  54. data/lib/instagram_connect/auth.rb +17 -0
  55. data/lib/instagram_connect/cli.rb +18 -0
  56. data/lib/instagram_connect/client.rb +160 -0
  57. data/lib/instagram_connect/configuration.rb +66 -0
  58. data/lib/instagram_connect/connect.rb +45 -0
  59. data/lib/instagram_connect/doctor.rb +22 -0
  60. data/lib/instagram_connect/engine.rb +28 -0
  61. data/lib/instagram_connect/errors.rb +25 -0
  62. data/lib/instagram_connect/ingest.rb +112 -0
  63. data/lib/instagram_connect/messaging_window.rb +42 -0
  64. data/lib/instagram_connect/railtie.rb +19 -0
  65. data/lib/instagram_connect/result.rb +34 -0
  66. data/lib/instagram_connect/signature_verifier.rb +23 -0
  67. data/lib/instagram_connect/version.rb +3 -0
  68. data/lib/instagram_connect.rb +53 -0
  69. 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