instagram_connect 0.2.0 → 0.3.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 (71) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/ci.yml +36 -0
  3. data/CHANGELOG.md +89 -0
  4. data/Gemfile +4 -0
  5. data/README.md +328 -73
  6. data/app/controllers/instagram_connect/comments_controller.rb +1 -1
  7. data/app/controllers/instagram_connect/posts_controller.rb +1 -1
  8. data/app/jobs/instagram_connect/account_readiness_job.rb +148 -0
  9. data/app/jobs/instagram_connect/fetch_media_job.rb +110 -0
  10. data/app/jobs/instagram_connect/profile_sync_job.rb +78 -0
  11. data/app/jobs/instagram_connect/replay_events_job.rb +57 -0
  12. data/app/jobs/instagram_connect/send_message_job.rb +44 -16
  13. data/app/models/instagram_connect/account.rb +27 -1
  14. data/app/models/instagram_connect/api_budget.rb +22 -0
  15. data/app/models/instagram_connect/conversation.rb +99 -11
  16. data/app/models/instagram_connect/inbound_message.rb +11 -3
  17. data/app/models/instagram_connect/insight_snapshot.rb +43 -0
  18. data/app/models/instagram_connect/media_item.rb +38 -0
  19. data/app/models/instagram_connect/mention.rb +37 -0
  20. data/app/models/instagram_connect/message.rb +34 -2
  21. data/app/models/instagram_connect/message_attachment.rb +53 -0
  22. data/app/models/instagram_connect/message_reaction.rb +33 -0
  23. data/app/models/instagram_connect/webhook_event.rb +67 -0
  24. data/db/migrate/20260715072548_create_instagram_connect_accounts.rb +2 -2
  25. data/db/migrate/20260715072549_create_instagram_connect_conversations.rb +2 -2
  26. data/db/migrate/20260715072550_create_instagram_connect_messages.rb +3 -3
  27. data/db/migrate/20260715072551_create_instagram_connect_inbound_messages.rb +2 -2
  28. data/db/migrate/20260715072552_create_instagram_connect_comments.rb +3 -3
  29. data/db/migrate/20260725194732_create_instagram_connect_webhook_events.rb +52 -0
  30. data/db/migrate/20260725200216_add_messaging_event_fields.rb +89 -0
  31. data/db/migrate/20260725200250_create_instagram_connect_message_reactions.rb +30 -0
  32. data/db/migrate/20260725201320_create_instagram_connect_message_attachments.rb +33 -0
  33. data/db/migrate/20260725202616_add_outbound_send_fields.rb +27 -0
  34. data/db/migrate/20260725203213_create_instagram_connect_media.rb +30 -0
  35. data/db/migrate/20260725203214_create_instagram_connect_mentions.rb +30 -0
  36. data/db/migrate/20260725203215_create_instagram_connect_insight_snapshots.rb +45 -0
  37. data/db/migrate/20260725203306_add_comment_moderation_fields.rb +29 -0
  38. data/db/migrate/20260725204022_add_per_account_token_fields.rb +76 -0
  39. data/db/migrate/20260725204815_create_instagram_connect_api_budgets.rb +29 -0
  40. data/db/migrate/20260725205417_add_conversation_profile_fields.rb +21 -0
  41. data/docs/CONFIGURATION.md +107 -0
  42. data/lib/instagram_connect/client.rb +109 -3
  43. data/lib/instagram_connect/configuration.rb +10 -0
  44. data/lib/instagram_connect/engine.rb +1 -0
  45. data/lib/instagram_connect/ingest/dispatcher.rb +151 -0
  46. data/lib/instagram_connect/ingest/envelope.rb +31 -0
  47. data/lib/instagram_connect/ingest/handlers/base.rb +55 -0
  48. data/lib/instagram_connect/ingest/handlers/comments.rb +59 -0
  49. data/lib/instagram_connect/ingest/handlers/mentions.rb +48 -0
  50. data/lib/instagram_connect/ingest/handlers/message_reactions.rb +91 -0
  51. data/lib/instagram_connect/ingest/handlers/messages.rb +135 -0
  52. data/lib/instagram_connect/ingest/handlers/messaging_handover.rb +53 -0
  53. data/lib/instagram_connect/ingest/handlers/messaging_optins.rb +37 -0
  54. data/lib/instagram_connect/ingest/handlers/messaging_postbacks.rb +24 -0
  55. data/lib/instagram_connect/ingest/handlers/messaging_referral.rb +42 -0
  56. data/lib/instagram_connect/ingest/handlers/messaging_seen.rb +37 -0
  57. data/lib/instagram_connect/ingest/handlers/story_insights.rb +62 -0
  58. data/lib/instagram_connect/ingest/handlers/unknown.rb +19 -0
  59. data/lib/instagram_connect/ingest/registry.rb +93 -0
  60. data/lib/instagram_connect/ingest/summary.rb +75 -0
  61. data/lib/instagram_connect/ingest.rb +27 -105
  62. data/lib/instagram_connect/media/attaching.rb +57 -0
  63. data/lib/instagram_connect/media/limits.rb +46 -0
  64. data/lib/instagram_connect/rate_limiter.rb +88 -0
  65. data/lib/instagram_connect/result.rb +26 -6
  66. data/lib/instagram_connect/text_splitter.rb +51 -0
  67. data/lib/instagram_connect/usage.rb +68 -0
  68. data/lib/instagram_connect/version.rb +1 -1
  69. data/lib/instagram_connect.rb +23 -0
  70. metadata +60 -2
  71. data/MIT-LICENSE +0 -20
@@ -33,6 +33,61 @@ module InstagramConnect
33
33
  })
34
34
  end
35
35
 
36
+ # Typing indicators and read receipts. Meta is explicit that a sender action
37
+ # request must carry ONLY the recipient and the action — bundling it with a
38
+ # message silently drops one of the two.
39
+ def send_sender_action(recipient_id:, action:)
40
+ post("/me/messages", { recipient: { id: recipient_id }, sender_action: action })
41
+ end
42
+
43
+ def send_quick_replies(recipient_id:, text:, replies:, tag: nil)
44
+ quick_replies = Array(replies).map do |reply|
45
+ { content_type: "text", title: reply[:title].to_s[0, 20], payload: reply[:payload].to_s }
46
+ end
47
+ send_message(recipient: { id: recipient_id },
48
+ message: { text: text, quick_replies: quick_replies }, tag: tag)
49
+ end
50
+
51
+ def send_attachment(recipient_id:, attachment_id:, tag: nil)
52
+ send_message(recipient: { id: recipient_id },
53
+ message: { attachment: { payload: { attachment_id: attachment_id } } }, tag: tag)
54
+ end
55
+
56
+ # Uploads bytes to Meta and returns a reusable attachment id.
57
+ #
58
+ # Preferred over handing Meta a signed URL to the host's storage: a signed
59
+ # URL is a bearer capability for a customer's file, sitting on the public
60
+ # internet for as long as its TTL, fetched from an address we do not
61
+ # control. This moves the bytes over an authenticated POST instead, and the
62
+ # returned id can be reused rather than re-uploading the same file.
63
+ # +file+ must be a File or Tempfile — HTTParty builds the multipart body
64
+ # from objects that expose a path, which is also what ActiveStorage's
65
+ # blob.open yields.
66
+ def upload_attachment(file:, type: "image")
67
+ body = {
68
+ platform: "instagram",
69
+ message: { attachment: { type: type, payload: { is_reusable: true } } }.to_json,
70
+ filedata: file
71
+ }
72
+ post_multipart("/me/message_attachments", body)
73
+ end
74
+
75
+ # Ice breakers and the persistent menu both live on the messenger profile.
76
+ def set_messenger_profile(**fields)
77
+ post("/me/messenger_profile", fields.merge(platform: "instagram"))
78
+ end
79
+
80
+ def messenger_profile(fields:)
81
+ get("/me/messenger_profile", { fields: Array(fields).join(","), platform: "instagram" })
82
+ end
83
+
84
+ def delete_messenger_profile(fields:)
85
+ parse(HTTParty.delete(url("/me/messenger_profile"),
86
+ headers: bearer.merge("Content-Type" => "application/json"),
87
+ body: { fields: Array(fields), platform: "instagram" }.to_json,
88
+ timeout: TIMEOUT))
89
+ end
90
+
36
91
  # One-time private reply to a comment (comment -> DM), valid 7 days.
37
92
  def private_reply(comment_id:, text:)
38
93
  post("/me/messages", { recipient: { comment_id: comment_id }, message: { text: text } })
@@ -85,6 +140,13 @@ module InstagramConnect
85
140
  get("/#{media_id}/insights", { metric: Array(metrics).join(",") })
86
141
  end
87
142
 
143
+ # The connected account's own profile. A different call from #profile,
144
+ # which looks up the person on the other end of a thread.
145
+ def account_profile(ig_user_id: @ig_user_id, fields: %w[username name profile_picture_url
146
+ followers_count media_count])
147
+ get("/#{require_ig_user_id(ig_user_id)}", { fields: Array(fields).join(",") })
148
+ end
149
+
88
150
  def profile(igsid:, fields: %w[name username profile_pic])
89
151
  get("/#{igsid}", { fields: Array(fields).join(",") })
90
152
  end
@@ -95,6 +157,38 @@ module InstagramConnect
95
157
  get("/me/accounts", { fields: "id,name,access_token,instagram_business_account" })
96
158
  end
97
159
 
160
+ # Walks a Graph collection to the end, following Meta's own cursors.
161
+ #
162
+ # Offsets are not safe here: a collection can shift between calls, so an
163
+ # offset silently skips or repeats rows. +limit_pages+ exists because these
164
+ # collections can be unbounded and every page spends rate budget.
165
+ def each_page(path, query = {}, limit_pages: 25)
166
+ cursor = nil
167
+ pages = 0
168
+
169
+ while pages < limit_pages
170
+ page_query = cursor ? query.merge(after: cursor) : query
171
+ result = get(path, page_query)
172
+ return result unless result.success?
173
+
174
+ yield result
175
+ pages += 1
176
+ cursor = result.next_cursor
177
+ break if cursor.nil?
178
+ end
179
+
180
+ Result.ok(data: { "pages" => pages })
181
+ end
182
+
183
+ # Every record across every page, for callers that just want the list.
184
+ def collect(path, query = {}, limit_pages: 25)
185
+ records = []
186
+ result = each_page(path, query, limit_pages: limit_pages) { |page| records.concat(page.records) }
187
+ return result if result.failure?
188
+
189
+ Result.ok(data: { "data" => records })
190
+ end
191
+
98
192
  def fetch_media_binary(url:)
99
193
  response = HTTParty.get(url, headers: bearer, timeout: TIMEOUT, follow_redirects: true)
100
194
  unless response.success?
@@ -135,6 +229,13 @@ module InstagramConnect
135
229
  parse(HTTParty.delete(url(path), headers: bearer, timeout: TIMEOUT))
136
230
  end
137
231
 
232
+ # multipart/form-data rather than JSON. The Attachment Upload API takes raw
233
+ # bytes, which a JSON body cannot carry.
234
+ def post_multipart(path, body)
235
+ parse(HTTParty.post(url(path), headers: bearer, body: body,
236
+ multipart: true, timeout: TIMEOUT))
237
+ end
238
+
138
239
  def url(path)
139
240
  "#{strategy.graph_host}/#{config.graph_version}#{path}"
140
241
  end
@@ -146,14 +247,19 @@ module InstagramConnect
146
247
  def parse(response)
147
248
  data = response.parsed_response
148
249
  data = {} unless data.is_a?(Hash)
250
+ usage = Usage.from_headers(response.headers)
149
251
  if response.success?
150
- Result.ok(id: data["message_id"] || data["id"], data: data)
252
+ Result.ok(id: data["message_id"] || data["id"], data: data, usage: usage)
151
253
  else
152
254
  err = data["error"].is_a?(Hash) ? data["error"] : {}
153
255
  Result.error(err["message"] || "HTTP #{response.code}",
154
256
  error_code: err["code"],
155
- retry_after: err.dig("error_data", "retry_after"),
156
- data: data)
257
+ # Meta names the wait in the error when it has one; when it
258
+ # does not, the usage header's estimate is the next best
259
+ # thing and is better than an invented backoff.
260
+ retry_after: err.dig("error_data", "retry_after") || usage&.retry_after,
261
+ data: data,
262
+ usage: usage)
157
263
  end
158
264
  end
159
265
  end
@@ -41,6 +41,16 @@ module InstagramConnect
41
41
  attr_reader :auth_path
42
42
  attr_writer :theme
43
43
 
44
+ # A copy of this configuration pinned to one auth path. Accounts store the
45
+ # path they were connected with, and a host can have both — so the global
46
+ # setting is the default for *new* connections, never an override on an
47
+ # existing one.
48
+ def for_auth_path(path)
49
+ return self if path.blank? || path.to_sym == auth_path
50
+
51
+ dup.tap { |copy| copy.auth_path = path }
52
+ end
53
+
44
54
  def initialize
45
55
  @auth_path = (ENV["INSTAGRAM_CONNECT_AUTH_PATH"] || "instagram_login").to_sym
46
56
  @app_id = ENV["INSTAGRAM_CONNECT_APP_ID"] || ENV["INSTAGRAM_APP_ID"]
@@ -43,6 +43,7 @@ module InstagramConnect
43
43
  # Encryption configured).
44
44
  config.to_prepare do
45
45
  InstagramConnect::Account.enable_token_encryption! if InstagramConnect.configuration.encrypt_tokens
46
+ InstagramConnect::MessageAttachment.enable_file_attachment! if defined?(ActiveStorage::Blob)
46
47
  end
47
48
  end
48
49
  end
@@ -0,0 +1,151 @@
1
+ require "digest"
2
+ require "json"
3
+
4
+ module InstagramConnect
5
+ class Ingest
6
+ # Walks a verified webhook payload, banks every event it contains on the
7
+ # ledger, and routes each one to its handler.
8
+ #
9
+ # Two rules shape this class:
10
+ #
11
+ # 1. Nothing is dropped. An event with no handler, or belonging to no
12
+ # connected account, still gets a row. Meta does not re-deliver, so a
13
+ # counter increment would be the only record that something was lost.
14
+ # 2. One bad event cannot poison the batch. Each is wrapped in its own
15
+ # rescue and marked `failed` with its error, leaving the rest to process.
16
+ class Dispatcher
17
+ def initialize(config)
18
+ @config = config
19
+ end
20
+
21
+ def call(payload)
22
+ summary = Summary.new
23
+ Array(payload && payload["entry"]).each do |entry|
24
+ process_entry(payload, entry, summary)
25
+ end
26
+ summary
27
+ end
28
+
29
+ private
30
+
31
+ attr_reader :config
32
+
33
+ def process_entry(payload, entry, summary)
34
+ account = account_for(entry)
35
+ entry_id = entry["id"].to_s
36
+ occurred_at = Envelope.time_from(entry["time"])
37
+
38
+ flatten(entry).each do |field, event|
39
+ process_event(
40
+ account: account, object: payload["object"], field: field, event: event,
41
+ entry_id: entry_id, entry_time: occurred_at, summary: summary
42
+ )
43
+ end
44
+ end
45
+
46
+ # Messaging events arrive under `entry.messaging` whatever subscription
47
+ # produced them, so their field is recovered by shape. `standby` wraps the
48
+ # same shapes for a thread this app does not currently control. Changes
49
+ # (comments, mentions, story insights) name their own field.
50
+ def flatten(entry)
51
+ pairs = Array(entry["messaging"]).map { |event| [ Registry.messaging_field_for(event), event ] }
52
+ pairs += Array(entry["standby"]).map { |event| [ "standby", event ] }
53
+ pairs + Array(entry["changes"]).map { |change| [ change["field"].to_s, change ] }
54
+ end
55
+
56
+ def process_event(account:, object:, field:, event:, entry_id:, entry_time:, summary:)
57
+ handler_class = Registry.handler_for(field)
58
+ key = dedupe_key(handler_class, field, event, entry_id)
59
+
60
+ # A handler that needs Meta's id cannot do anything useful without one.
61
+ # An event like this used to be counted as skipped; banking it means an
62
+ # operator can see that Meta sent something malformed.
63
+ if handler_class.respond_to?(:requires_dedupe_key?) && handler_class.requires_dedupe_key? &&
64
+ handler_class.dedupe_key(event).blank?
65
+ record_unhandled(account, object, field, event, entry_id, entry_time, key, summary)
66
+ return
67
+ end
68
+
69
+ record = WebhookEvent.claim(
70
+ account: account, object: object, field: field,
71
+ event_type: field, dedupe_key: key, entry_id: entry_id,
72
+ occurred_at: event_time(event) || entry_time, payload: event,
73
+ handler: handler_class.name
74
+ )
75
+
76
+ if record.nil?
77
+ summary.duplicates += 1
78
+ return
79
+ end
80
+
81
+ if account.nil? || !Registry.handled?(field)
82
+ record.mark_unhandled!
83
+ summary.unhandled += 1
84
+ return
85
+ end
86
+
87
+ run(handler_class, record, account, object, field, event, entry_id, entry_time, summary)
88
+ end
89
+
90
+ def run(handler_class, record, account, object, field, event, entry_id, entry_time, summary)
91
+ envelope = Envelope.new(
92
+ account: account, object: object, field: field, event_type: field,
93
+ event: event, entry_id: entry_id,
94
+ occurred_at: event_time(event) || entry_time, config: config
95
+ )
96
+ handler = handler_class.new(envelope)
97
+ subject = handler.call
98
+ record.mark_processed!(subject)
99
+ summary.count(field)
100
+ deliver(handler.notification, summary)
101
+ rescue StandardError => e
102
+ record.mark_failed!(e)
103
+ summary.failed += 1
104
+ end
105
+
106
+ # The host callback runs outside the handler, in its own rescue. The rows
107
+ # are already committed at this point, so a host hook raising must not
108
+ # flip the event to failed — it would be replayed and duplicate the data.
109
+ def deliver(notification, summary)
110
+ return if notification.nil?
111
+
112
+ handler, argument = notification
113
+ handler.call(argument) if handler.respond_to?(:call)
114
+ rescue StandardError => e
115
+ summary.callback_errors += 1
116
+ config.logger&.error("[InstagramConnect] host callback raised: #{e.class}: #{e.message}")
117
+ end
118
+
119
+ def record_unhandled(account, object, field, event, entry_id, entry_time, key, summary)
120
+ record = WebhookEvent.claim(
121
+ account: account, object: object, field: field, event_type: field,
122
+ dedupe_key: key, entry_id: entry_id,
123
+ occurred_at: event_time(event) || entry_time, payload: event,
124
+ handler: Handlers::Unknown.name
125
+ )
126
+ if record.nil?
127
+ summary.duplicates += 1
128
+ else
129
+ record.mark_unhandled!
130
+ summary.unhandled += 1
131
+ end
132
+ end
133
+
134
+ def dedupe_key(handler_class, field, event, entry_id)
135
+ handler_class.dedupe_key(event).presence ||
136
+ Digest::SHA256.hexdigest("#{field}:#{entry_id}:#{JSON.generate(event)}")
137
+ end
138
+
139
+ def event_time(event)
140
+ Envelope.time_from(event["timestamp"])
141
+ end
142
+
143
+ def account_for(entry)
144
+ id = entry["id"].to_s
145
+ return nil if id.empty?
146
+
147
+ Account.find_by(ig_user_id: id) || Account.find_by(page_id: id)
148
+ end
149
+ end
150
+ end
151
+ end
@@ -0,0 +1,31 @@
1
+ module InstagramConnect
2
+ class Ingest
3
+ # One webhook event, plus the context needed to interpret it: which account
4
+ # it belongs to, which field Meta delivered it under, and when it actually
5
+ # happened on the wire (as opposed to when we got round to storing it).
6
+ Envelope = Struct.new(
7
+ :account, :object, :field, :event_type, :event, :entry_id, :occurred_at, :config,
8
+ keyword_init: true
9
+ ) do
10
+ # Anything outside this is not a timestamp Meta could have sent, and
11
+ # storing it would corrupt every ordering that reads occurred_at.
12
+ MAX_SECONDS = 4_102_444_800 # 2100-01-01
13
+
14
+ # Meta timestamps are milliseconds since the epoch. Ordering off this
15
+ # rather than our own created_at is what stops a delayed webhook batch
16
+ # silently reordering a conversation.
17
+ def self.time_from(millis)
18
+ return nil if millis.nil?
19
+
20
+ seconds = millis.to_i / 1000.0
21
+ return nil unless seconds.finite? && seconds >= 0 && seconds <= MAX_SECONDS
22
+
23
+ Time.at(seconds).utc
24
+ end
25
+
26
+ def dig(*keys)
27
+ event.dig(*keys)
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,55 @@
1
+ module InstagramConnect
2
+ class Ingest
3
+ module Handlers
4
+ # A handler turns one webhook event into persisted rows and returns the
5
+ # row it produced (or nil), which the dispatcher records on the ledger so
6
+ # an operator can trace an event to its result.
7
+ #
8
+ # Subclasses implement `.dedupe_key` and `#call`.
9
+ class Base
10
+ # Meta's own identifier for this event, used as the dedupe claim. Nil
11
+ # means the event carries no stable id and the dispatcher falls back to
12
+ # hashing the payload.
13
+ def self.dedupe_key(_event)
14
+ nil
15
+ end
16
+
17
+ # The host callback this handler wants fired, and its argument. Read by
18
+ # the dispatcher *after* `#call` returns, so the callback runs outside
19
+ # the handler in its own rescue: a host hook must never roll back an
20
+ # ingest whose data is already stored.
21
+ attr_reader :notification
22
+
23
+ def initialize(envelope)
24
+ @envelope = envelope
25
+ @notification = nil
26
+ end
27
+
28
+ def call
29
+ raise NotImplementedError
30
+ end
31
+
32
+ private
33
+
34
+ attr_reader :envelope
35
+
36
+ def account
37
+ envelope.account
38
+ end
39
+
40
+ def event
41
+ envelope.event
42
+ end
43
+
44
+ def config
45
+ envelope.config
46
+ end
47
+
48
+ def notify(handler, argument)
49
+ @notification = [ handler, argument ]
50
+ argument
51
+ end
52
+ end
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,59 @@
1
+ module InstagramConnect
2
+ class Ingest
3
+ module Handlers
4
+ # A comment on the account's own media. Also covers `live_comments`,
5
+ # which carry the same shape — the difference is that a private reply to
6
+ # a live comment is only valid while the broadcast is running.
7
+ class Comments < Base
8
+ def self.dedupe_key(event)
9
+ event.dig("value", "id").presence
10
+ end
11
+
12
+ def self.requires_dedupe_key?
13
+ true
14
+ end
15
+
16
+ def call
17
+ comment = InstagramConnect::Comment.record(
18
+ account: account,
19
+ comment_id: value["id"].to_s,
20
+ media_id: value.dig("media", "id"),
21
+ text: value["text"],
22
+ from_username: value.dig("from", "username"),
23
+ parent_id: value["parent_id"]
24
+ )
25
+ comment.update!(
26
+ from_ig_id: value.dig("from", "id"),
27
+ commented_at: commented_at,
28
+ # A private reply to a live comment is only valid while the
29
+ # broadcast is running, so which field delivered this decides
30
+ # whether that action can be offered at all.
31
+ is_live: envelope.field == "live_comments"
32
+ )
33
+
34
+ record_media(comment)
35
+ notify(config.on_comment, comment)
36
+ comment
37
+ end
38
+
39
+ private
40
+
41
+ def value
42
+ event["value"] || {}
43
+ end
44
+
45
+ def commented_at
46
+ envelope.occurred_at || Time.current
47
+ end
48
+
49
+ # The comment names a media we may never have seen — it could predate
50
+ # this install, or have been posted from a phone.
51
+ def record_media(comment)
52
+ return if comment.media_id.blank?
53
+
54
+ InstagramConnect::MediaItem.record(account: account, ig_media_id: comment.media_id)
55
+ end
56
+ end
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,48 @@
1
+ module InstagramConnect
2
+ class Ingest
3
+ module Handlers
4
+ # Someone @mentioned the account, in a comment or in a caption.
5
+ #
6
+ # Meta does not store these notifications and never re-delivers them, so
7
+ # this handler is the single point at which the event either becomes a row
8
+ # or ceases to exist. Nothing here is allowed to be best-effort.
9
+ class Mentions < Base
10
+ def self.dedupe_key(event)
11
+ value = event["value"] || {}
12
+ media = value["media_id"].presence
13
+ return nil if media.nil?
14
+
15
+ "#{media}:#{value['comment_id'].presence || 'caption'}"
16
+ end
17
+
18
+ def self.requires_dedupe_key?
19
+ true
20
+ end
21
+
22
+ def call
23
+ InstagramConnect::MediaItem.record(account: account, ig_media_id: value["media_id"])
24
+
25
+ InstagramConnect::Mention.record(
26
+ account: account,
27
+ kind: value["comment_id"].present? ? "comment" : "caption",
28
+ ig_media_id: value["media_id"],
29
+ comment_id: value["comment_id"],
30
+ text: value["text"],
31
+ from_username: value.dig("from", "username"),
32
+ mentioned_at: mentioned_at
33
+ )
34
+ end
35
+
36
+ private
37
+
38
+ def value
39
+ event["value"] || {}
40
+ end
41
+
42
+ def mentioned_at
43
+ envelope.occurred_at || Time.current
44
+ end
45
+ end
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,91 @@
1
+ module InstagramConnect
2
+ class Ingest
3
+ module Handlers
4
+ # A customer reacting to (or un-reacting from) a message.
5
+ #
6
+ # Reactions also reopen the 24-hour messaging window, which is why they
7
+ # bump the conversation's activity even though they are not messages.
8
+ class MessageReactions < Base
9
+ # A customer can react, unreact, then react again with the same emoji on
10
+ # the same message. Those are three real events sharing a mid, so the
11
+ # action and the wire timestamp both belong in the key. A genuine
12
+ # redelivery repeats the timestamp and still collapses, which is the
13
+ # distinction that matters.
14
+ def self.dedupe_key(event)
15
+ reaction = event["reaction"] || {}
16
+ mid = reaction["mid"].presence or return nil
17
+
18
+ [ mid, reaction["action"], reaction["reaction"], event["timestamp"] ].compact.join(":")
19
+ end
20
+
21
+ def call
22
+ record = InstagramConnect::MessageReaction.create!(
23
+ conversation: conversation,
24
+ message: message,
25
+ ig_message_id: mid,
26
+ igsid: igsid,
27
+ actor: actor,
28
+ action: action,
29
+ reaction: payload["reaction"],
30
+ emoji: payload["emoji"],
31
+ reacted_at: reacted_at
32
+ )
33
+ refresh_message_projection
34
+ conversation.register_event(reacted_at)
35
+ record
36
+ end
37
+
38
+ private
39
+
40
+ def payload
41
+ event["reaction"] || {}
42
+ end
43
+
44
+ def mid
45
+ payload["mid"].to_s
46
+ end
47
+
48
+ def action
49
+ payload["action"] == "unreact" ? "unreact" : "react"
50
+ end
51
+
52
+ # A reaction the business left shows up as an event from the account
53
+ # itself rather than from the customer.
54
+ def actor
55
+ igsid == account.ig_user_id ? "business" : "customer"
56
+ end
57
+
58
+ def igsid
59
+ event.dig("sender", "id").to_s
60
+ end
61
+
62
+ def reacted_at
63
+ envelope.occurred_at || Time.current
64
+ end
65
+
66
+ def message
67
+ @message ||= InstagramConnect::Message.find_by(ig_message_id: mid)
68
+ end
69
+
70
+ def conversation
71
+ @conversation ||= message&.conversation ||
72
+ InstagramConnect::Conversation.locate(account: account, igsid: igsid)
73
+ end
74
+
75
+ # Denormalized onto the message so a bubble renders without a join.
76
+ # Recomputed from the log rather than incremented, so an out-of-order
77
+ # unreact cannot leave the projection disagreeing with its source.
78
+ def refresh_message_projection
79
+ return if message.nil?
80
+
81
+ current = InstagramConnect::MessageReaction.current_for(mid)
82
+ InstagramConnect::Message.where(id: message.id).update_all(
83
+ reactions_count: InstagramConnect::MessageReaction.where(ig_message_id: mid, action: "react").count,
84
+ last_reaction: current&.reaction,
85
+ updated_at: Time.current
86
+ )
87
+ end
88
+ end
89
+ end
90
+ end
91
+ end