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,112 @@
1
+ module InstagramConnect
2
+ # Parses a verified Meta webhook payload and persists it into the engine's
3
+ # chat models, deduping on Meta's message id. After persistence it fires the
4
+ # configured host handlers (on_message / on_comment / on_postback) so the host
5
+ # can layer extras (notifications, AI replies) on top.
6
+ #
7
+ # Returns a summary hash of what was processed.
8
+ class Ingest
9
+ def self.call(payload:, config: InstagramConnect.configuration)
10
+ new(config).call(payload)
11
+ end
12
+
13
+ def initialize(config)
14
+ @config = config
15
+ end
16
+
17
+ def call(payload)
18
+ summary = { messages: 0, comments: 0, postbacks: 0, skipped: 0 }
19
+ Array(payload && payload["entry"]).each do |entry|
20
+ account = account_for(entry)
21
+ if account.nil?
22
+ summary[:skipped] += 1
23
+ next
24
+ end
25
+ Array(entry["messaging"]).each { |event| ingest_messaging(account, event, summary) }
26
+ Array(entry["changes"]).each { |change| ingest_change(account, change, summary) }
27
+ end
28
+ summary
29
+ end
30
+
31
+ private
32
+
33
+ attr_reader :config
34
+
35
+ def account_for(entry)
36
+ id = entry["id"].to_s
37
+ Account.find_by(ig_user_id: id) || Account.find_by(page_id: id)
38
+ end
39
+
40
+ def ingest_messaging(account, event, summary)
41
+ if event["message"]
42
+ ingest_message(account, event, summary)
43
+ elsif event["postback"]
44
+ invoke(config.on_postback, build_postback(account, event))
45
+ summary[:postbacks] += 1
46
+ else
47
+ summary[:skipped] += 1
48
+ end
49
+ end
50
+
51
+ def ingest_message(account, event, summary)
52
+ msg = event["message"]
53
+ mid = msg["mid"].to_s
54
+ if mid.empty? || !InboundMessage.claim(ig_message_id: mid, account_id: account.id)
55
+ summary[:skipped] += 1
56
+ return
57
+ end
58
+
59
+ echo = msg["is_echo"] ? true : false
60
+ igsid = (echo ? event.dig("recipient", "id") : event.dig("sender", "id")).to_s
61
+ conversation = Conversation.locate(account: account, igsid: igsid)
62
+ message = Message.create!(
63
+ conversation: conversation,
64
+ direction: echo ? "outbound" : "inbound",
65
+ status: echo ? "sent" : "received",
66
+ source: echo ? "operator_app" : "inbound",
67
+ kind: "dm",
68
+ body: msg["text"],
69
+ ig_message_id: mid,
70
+ media_status: media?(msg) ? "pending" : "none"
71
+ )
72
+ conversation.register_message(message)
73
+ invoke(config.on_message, message) unless echo
74
+ summary[:messages] += 1
75
+ end
76
+
77
+ def ingest_change(account, change, summary)
78
+ unless change["field"] == "comments"
79
+ summary[:skipped] += 1
80
+ return
81
+ end
82
+ value = change["value"] || {}
83
+ comment = Comment.record(
84
+ account: account,
85
+ comment_id: value["id"].to_s,
86
+ media_id: value.dig("media", "id"),
87
+ text: value["text"],
88
+ from_username: value.dig("from", "username"),
89
+ parent_id: value["parent_id"]
90
+ )
91
+ invoke(config.on_comment, comment)
92
+ summary[:comments] += 1
93
+ end
94
+
95
+ def media?(msg)
96
+ Array(msg["attachments"]).any?
97
+ end
98
+
99
+ def build_postback(account, event)
100
+ {
101
+ account_id: account.id,
102
+ sender_id: event.dig("sender", "id"),
103
+ payload: event.dig("postback", "payload"),
104
+ title: event.dig("postback", "title")
105
+ }
106
+ end
107
+
108
+ def invoke(handler, arg)
109
+ handler.call(arg) if handler.respond_to?(:call)
110
+ end
111
+ end
112
+ end
@@ -0,0 +1,42 @@
1
+ module InstagramConnect
2
+ # Encodes Meta's outbound messaging window. After a user's last inbound
3
+ # message a business may send standard replies for 24h; the HUMAN_AGENT tag
4
+ # extends that to 7 days (human replies only). Beyond that, nothing may be
5
+ # sent until the user messages again.
6
+ class MessagingWindow
7
+ STANDARD_SECONDS = 24 * 60 * 60
8
+ HUMAN_AGENT_SECONDS = 7 * 24 * 60 * 60
9
+
10
+ def initialize(last_inbound_at:, now: Time.now)
11
+ @last_inbound_at = last_inbound_at
12
+ @now = now
13
+ end
14
+
15
+ def state
16
+ return :closed if @last_inbound_at.nil?
17
+
18
+ elapsed = @now - @last_inbound_at
19
+ return :standard if elapsed <= STANDARD_SECONDS
20
+ return :human_agent if elapsed <= HUMAN_AGENT_SECONDS
21
+
22
+ :closed
23
+ end
24
+
25
+ def open?
26
+ state != :closed
27
+ end
28
+
29
+ def standard?
30
+ state == :standard
31
+ end
32
+
33
+ # The message tag to send with, or :blocked when no send is permitted.
34
+ def send_tag
35
+ case state
36
+ when :standard then nil
37
+ when :human_agent then "HUMAN_AGENT"
38
+ else :blocked
39
+ end
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,19 @@
1
+ require "rails/railtie"
2
+
3
+ module InstagramConnect
4
+ # Loaded when Rails is present but the full Engine is not (e.g. a bare Rails
5
+ # app that only wants the client, not the mounted UI). Mirrors the engine's
6
+ # configure initializer.
7
+ class Railtie < ::Rails::Railtie
8
+ config.instagram_connect = {}
9
+
10
+ initializer "instagram_connect.configure" do |app|
11
+ InstagramConnect.configure do |config|
12
+ app.config.instagram_connect.each do |key, value|
13
+ setter = "#{key}="
14
+ config.public_send(setter, value) if config.respond_to?(setter)
15
+ end
16
+ end
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,34 @@
1
+ module InstagramConnect
2
+ # Value object returned by every Client call. Client methods never raise for
3
+ # API-level failures — callers branch on +success?+ and read +error_code+ /
4
+ # +error_message+. Transport/programming errors still raise.
5
+ class Result
6
+ attr_reader :success, :id, :error_code, :error_message, :retry_after, :data
7
+
8
+ def initialize(success:, id: nil, error_code: nil, error_message: nil, retry_after: nil, data: {})
9
+ @success = success
10
+ @id = id
11
+ @error_code = error_code
12
+ @error_message = error_message
13
+ @retry_after = retry_after
14
+ @data = data || {}
15
+ end
16
+
17
+ def success?
18
+ success
19
+ end
20
+
21
+ def failure?
22
+ !success?
23
+ end
24
+
25
+ # Convenience builders so call sites read cleanly.
26
+ def self.ok(id: nil, data: {})
27
+ new(success: true, id: id, data: data)
28
+ end
29
+
30
+ def self.error(message, error_code: nil, retry_after: nil, data: {})
31
+ new(success: false, error_message: message, error_code: error_code, retry_after: retry_after, data: data)
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,23 @@
1
+ require "openssl"
2
+
3
+ module InstagramConnect
4
+ # Verifies Meta's `X-Hub-Signature-256` header: HMAC-SHA256 of the raw request
5
+ # body keyed by the app secret, compared in constant time. This is how the
6
+ # webhook authenticates that a POST really came from Meta.
7
+ module SignatureVerifier
8
+ module_function
9
+
10
+ def valid?(raw_body:, signature:, app_secret: InstagramConnect.configuration.app_secret)
11
+ return false if signature.to_s.empty? || app_secret.to_s.empty?
12
+
13
+ expected = "sha256=#{OpenSSL::HMAC.hexdigest('SHA256', app_secret.to_s, raw_body.to_s)}"
14
+ secure_compare(signature.to_s, expected)
15
+ end
16
+
17
+ def secure_compare(given, expected)
18
+ return false unless given.bytesize == expected.bytesize
19
+
20
+ OpenSSL.fixed_length_secure_compare(given, expected)
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,3 @@
1
+ module InstagramConnect
2
+ VERSION = "0.1.0"
3
+ end
@@ -0,0 +1,53 @@
1
+ require "logger"
2
+ require_relative "instagram_connect/version"
3
+ require_relative "instagram_connect/errors"
4
+ require_relative "instagram_connect/result"
5
+ require_relative "instagram_connect/configuration"
6
+ require_relative "instagram_connect/auth/strategy"
7
+ require_relative "instagram_connect/auth/instagram_login"
8
+ require_relative "instagram_connect/auth/facebook_login"
9
+ require_relative "instagram_connect/auth"
10
+ require_relative "instagram_connect/client"
11
+ require_relative "instagram_connect/connect"
12
+ require_relative "instagram_connect/signature_verifier"
13
+ require_relative "instagram_connect/messaging_window"
14
+ require_relative "instagram_connect/ingest"
15
+ require_relative "instagram_connect/doctor"
16
+
17
+ # InstagramConnect connects a Rails app to Instagram over the official Meta
18
+ # Graph API: receive and reply to DMs and comments in real time via
19
+ # HMAC-verified webhooks, publish posts, and manage OAuth tokens.
20
+ #
21
+ # InstagramConnect.configure do |c|
22
+ # c.auth_path = :facebook_login
23
+ # c.app_id = Rails.application.credentials.dig(:instagram_connect, :app_id)
24
+ # c.app_secret = Rails.application.credentials.dig(:instagram_connect, :app_secret)
25
+ # c.verify_token = Rails.application.credentials.dig(:instagram_connect, :verify_token)
26
+ # end
27
+ module InstagramConnect
28
+ class << self
29
+ def configuration
30
+ @configuration ||= Configuration.new
31
+ end
32
+
33
+ attr_writer :configuration
34
+
35
+ def configure
36
+ yield(configuration)
37
+ configuration.validate!
38
+ configuration
39
+ end
40
+
41
+ def reset!
42
+ @configuration = Configuration.new
43
+ end
44
+ end
45
+ end
46
+
47
+ # simplecov:disable
48
+ if defined?(Rails::Engine)
49
+ require_relative "instagram_connect/engine"
50
+ elsif defined?(Rails::Railtie)
51
+ require_relative "instagram_connect/railtie"
52
+ end
53
+ # simplecov:enable
metadata ADDED
@@ -0,0 +1,160 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: instagram_connect
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Kshitiz Sinha
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: rails
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: '7.1'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - ">="
24
+ - !ruby/object:Gem::Version
25
+ version: '7.1'
26
+ - !ruby/object:Gem::Dependency
27
+ name: httparty
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - ">="
31
+ - !ruby/object:Gem::Version
32
+ version: '0.21'
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - ">="
38
+ - !ruby/object:Gem::Version
39
+ version: '0.21'
40
+ - !ruby/object:Gem::Dependency
41
+ name: thor
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - ">="
45
+ - !ruby/object:Gem::Version
46
+ version: '1.0'
47
+ type: :runtime
48
+ prerelease: false
49
+ version_requirements: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - ">="
52
+ - !ruby/object:Gem::Version
53
+ version: '1.0'
54
+ description: 'A mountable Rails engine that connects your app to Instagram: receive
55
+ and reply to DMs and comments in real time over HMAC-verified webhooks, publish
56
+ posts, and manage OAuth tokens — using the official Instagram Graph API (Instagram-Login
57
+ or Facebook-Login), no unofficial automation.'
58
+ email:
59
+ - kshtzkr@gmail.com
60
+ executables:
61
+ - instagram_connect
62
+ extensions: []
63
+ extra_rdoc_files: []
64
+ files:
65
+ - ".github/workflows/ci.yml"
66
+ - ".rspec"
67
+ - CHANGELOG.md
68
+ - Gemfile
69
+ - LICENSE.txt
70
+ - MIT-LICENSE
71
+ - README.md
72
+ - Rakefile
73
+ - app/controllers/instagram_connect/application_controller.rb
74
+ - app/controllers/instagram_connect/comments_controller.rb
75
+ - app/controllers/instagram_connect/conversations_controller.rb
76
+ - app/controllers/instagram_connect/messages_controller.rb
77
+ - app/controllers/instagram_connect/oauth_controller.rb
78
+ - app/controllers/instagram_connect/posts_controller.rb
79
+ - app/controllers/instagram_connect/webhooks_controller.rb
80
+ - app/jobs/instagram_connect/application_job.rb
81
+ - app/jobs/instagram_connect/ingest_job.rb
82
+ - app/jobs/instagram_connect/refresh_tokens_job.rb
83
+ - app/jobs/instagram_connect/send_message_job.rb
84
+ - app/models/instagram_connect/account.rb
85
+ - app/models/instagram_connect/application_record.rb
86
+ - app/models/instagram_connect/comment.rb
87
+ - app/models/instagram_connect/conversation.rb
88
+ - app/models/instagram_connect/inbound_message.rb
89
+ - app/models/instagram_connect/message.rb
90
+ - app/views/instagram_connect/comments/_comment.html.erb
91
+ - app/views/instagram_connect/comments/index.html.erb
92
+ - app/views/instagram_connect/conversations/_composer.html.erb
93
+ - app/views/instagram_connect/conversations/_conversation.html.erb
94
+ - app/views/instagram_connect/conversations/_message.html.erb
95
+ - app/views/instagram_connect/conversations/index.html.erb
96
+ - app/views/instagram_connect/conversations/show.html.erb
97
+ - app/views/instagram_connect/posts/index.html.erb
98
+ - app/views/instagram_connect/posts/new.html.erb
99
+ - app/views/layouts/instagram_connect/application.html.erb
100
+ - bin/instagram_connect
101
+ - config/routes.rb
102
+ - docs/app_review_guide.md
103
+ - docs/auth_paths.md
104
+ - docs/roadmap.md
105
+ - lib/generators/instagram_connect/controllers_generator.rb
106
+ - lib/generators/instagram_connect/install/install_generator.rb
107
+ - lib/generators/instagram_connect/install/templates/create_instagram_connect_accounts.rb.tt
108
+ - lib/generators/instagram_connect/install/templates/create_instagram_connect_comments.rb.tt
109
+ - lib/generators/instagram_connect/install/templates/create_instagram_connect_conversations.rb.tt
110
+ - lib/generators/instagram_connect/install/templates/create_instagram_connect_inbound_messages.rb.tt
111
+ - lib/generators/instagram_connect/install/templates/create_instagram_connect_messages.rb.tt
112
+ - lib/generators/instagram_connect/install/templates/instagram_connect.rb.tt
113
+ - lib/generators/instagram_connect/views_generator.rb
114
+ - lib/instagram_connect.rb
115
+ - lib/instagram_connect/auth.rb
116
+ - lib/instagram_connect/auth/facebook_login.rb
117
+ - lib/instagram_connect/auth/instagram_login.rb
118
+ - lib/instagram_connect/auth/strategy.rb
119
+ - lib/instagram_connect/cli.rb
120
+ - lib/instagram_connect/client.rb
121
+ - lib/instagram_connect/configuration.rb
122
+ - lib/instagram_connect/connect.rb
123
+ - lib/instagram_connect/doctor.rb
124
+ - lib/instagram_connect/engine.rb
125
+ - lib/instagram_connect/errors.rb
126
+ - lib/instagram_connect/ingest.rb
127
+ - lib/instagram_connect/messaging_window.rb
128
+ - lib/instagram_connect/railtie.rb
129
+ - lib/instagram_connect/result.rb
130
+ - lib/instagram_connect/signature_verifier.rb
131
+ - lib/instagram_connect/version.rb
132
+ homepage: https://github.com/kshtzkr/instagram_connect
133
+ licenses:
134
+ - MIT
135
+ metadata:
136
+ allowed_push_host: https://rubygems.org
137
+ homepage_uri: https://github.com/kshtzkr/instagram_connect
138
+ source_code_uri: https://github.com/kshtzkr/instagram_connect/tree/main
139
+ changelog_uri: https://github.com/kshtzkr/instagram_connect/releases
140
+ bug_tracker_uri: https://github.com/kshtzkr/instagram_connect/issues
141
+ rubygems_mfa_required: 'true'
142
+ rdoc_options: []
143
+ require_paths:
144
+ - lib
145
+ required_ruby_version: !ruby/object:Gem::Requirement
146
+ requirements:
147
+ - - ">="
148
+ - !ruby/object:Gem::Version
149
+ version: '3.1'
150
+ required_rubygems_version: !ruby/object:Gem::Requirement
151
+ requirements:
152
+ - - ">="
153
+ - !ruby/object:Gem::Version
154
+ version: '0'
155
+ requirements: []
156
+ rubygems_version: 4.0.3
157
+ specification_version: 4
158
+ summary: Instagram DMs, comments, and publishing for Rails via the official Meta Graph
159
+ API.
160
+ test_files: []