instagram_connect 0.3.13 → 0.3.14

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: acad3028e8aecad50414e6bd980d896ca1b5c8245b28d517889b1cbfd5cca8ce
4
- data.tar.gz: d7be5c7d2b2da1ba842cf1558a38de3d820557f4b982892c047c10d76b5b0744
3
+ metadata.gz: d434793fea3922a883a5c471a9b8ac915fd9495e1d33249c0e8eaaa3a0ad5a57
4
+ data.tar.gz: ce5ba440af874f60ffd385c0f1bb9ec9163e133ad9ae82fee572f9a4a31fdd34
5
5
  SHA512:
6
- metadata.gz: 00e7e104a3ac1cc0cfcca780868f4c046ff4d593ce37606862f6d81b20971dfc66edcbd070018a1775e60ae2143393f9ce42b00ecffb835504cd093579e04964
7
- data.tar.gz: 95e0f2ee5b9462277e0f87156288a31314a7b73888a0a38aa411340f25f98a4c646e04d45989c638dc6f0664982bf9f11d85aed3e3ebaefbb151a3e4f143ed8e
6
+ metadata.gz: a891502b9568dabd9836bc7b33e37823b6552b390b89ded7391d80af5a1c5799ac8486ff8424096f4bedc4497d0f1f5027845f46a7ee169224d19291bb87b00f
7
+ data.tar.gz: decbeeebb16e42c4e08213276bc910c3f1930ada5dffcf369a4e93f689afe8a94efed8a0964e9d79a5173616b3664ae60751c33bb997669de06d8165c22bfbc2
data/CHANGELOG.md CHANGED
@@ -6,10 +6,41 @@ All notable changes to this project are documented here. The format follows
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.3.14] - 2026-07-28
10
+
11
+ ### Added
12
+
13
+ - **Outbound attachments.** `UploadAttachmentJob` uploads a message's file to Meta and records the
14
+ reusable `attachment_upload_id`; `SendMessageJob` then sends the attachment BEFORE the words
15
+ about it, so the customer sees the photo and then the caption in the order the operator wrote
16
+ them. Two steps rather than one because a file Meta rejects (too large, wrong type) must fail
17
+ the message with THAT reason rather than the send's. Size ceilings are checked before an upload
18
+ is spent, and `attachment_delivered_at` is the resume cursor so a worker killed between the two
19
+ halves never shows the customer the photo twice. `upload_attachment`/`send_attachment` had
20
+ existed with zero callers since 0.1.
21
+
22
+ ### Fixed
23
+
24
+ - An attachment-only message no longer posts an empty text alongside it: splitting `""` yields one
25
+ empty part, which was sent as a message.
26
+
27
+ ### Migration
28
+
29
+ - `add_column :instagram_connect_messages, :attachment_delivered_at, :datetime` (`if_not_exists`).
30
+
31
+
9
32
  ## [0.3.13] - 2026-07-28
10
33
 
11
34
  ### Fixed
12
35
 
36
+ - **An echo of a message this system sent no longer collides with it.** Every outbound send stores
37
+ Meta's mid; the echo of that same message then arrived and the handler called `create!` on the
38
+ same mid, so the event was banked `failed` and every replay failed again — forever
39
+ (`PG::UniqueViolation` on `index_instagram_connect_messages_on_account_and_mid`, hourly, in
40
+ production). The echo now ENRICHES the row it is echoing — filling only what the original write
41
+ could not know — and never rewrites direction, status, source or body, so a CMS-sent message
42
+ cannot be turned into one that looks sent from the phone.
43
+
13
44
  - **The readiness pass was the one path still shipping an unreadable token to Meta.** It builds its
14
45
  own `Authorization` header rather than going through `Account#client`, so 0.3.12's guard never
15
46
  covered it: Meta answered "Cannot parse access token" and the failure was recorded as if the
@@ -41,7 +41,26 @@ module InstagramConnect
41
41
 
42
42
  def deliver(message, conversation, tag)
43
43
  client = conversation.account.client
44
- parts = TextSplitter.split(message.body.to_s)
44
+
45
+ # The attachment goes first, so the customer sees the photo and then the
46
+ # words about it — the order the operator wrote them in. Sent by
47
+ # reference: attachment_upload_id is Meta's reusable id, so the same file
48
+ # is uploaded once however many threads it is sent to.
49
+ if message.attachment_upload_id.present? && !message.attachment_delivered?
50
+ result = client.send_attachment(recipient_id: conversation.igsid,
51
+ attachment_id: message.attachment_upload_id, tag: tag)
52
+ unless result.success?
53
+ return fail_message(message, result.error_code&.to_s, result.error_message)
54
+ end
55
+
56
+ message.update!(attachment_delivered_at: Time.current,
57
+ ig_message_id: message.ig_message_id.presence || result.id)
58
+ end
59
+
60
+ # reject(&:blank?): an attachment-only message has no text, and splitting
61
+ # "" yields one empty part — which would post an empty message Meta has
62
+ # no reason to accept.
63
+ parts = TextSplitter.split(message.body.to_s).reject(&:blank?)
45
64
 
46
65
  # Resume rather than restart. A worker killed mid-fan-out has already
47
66
  # delivered the earlier parts, and re-sending them repeats text the
@@ -0,0 +1,66 @@
1
+ module InstagramConnect
2
+ # Uploads a message's file to Meta and records the reusable attachment id,
3
+ # then hands the message to the sender.
4
+ #
5
+ # Two steps rather than one because Meta's upload is a separate call with its
6
+ # own failure mode: a file it rejects (too large, wrong type) must fail the
7
+ # message with THAT reason, not with whatever the send would have said
8
+ # afterwards. The id is reusable, so the same file sent to a second thread
9
+ # costs no upload at all.
10
+ #
11
+ # The host attaches the file however it likes (Active Storage, a Tempfile);
12
+ # this job only needs something that responds to #open or #path.
13
+ class UploadAttachmentJob < ApplicationJob
14
+ queue_as :default
15
+
16
+ # Meta's own ceilings, stated here so a file that cannot possibly work is
17
+ # refused before it costs an upload.
18
+ SIZE_LIMITS = { "image" => 8.megabytes, "video" => 25.megabytes,
19
+ "audio" => 25.megabytes, "file" => 25.megabytes }.freeze
20
+
21
+ def perform(message_id, file_path, type: "image")
22
+ message = Message.find_by(id: message_id)
23
+ return if message.nil? || message.attachment_upload_id.present?
24
+
25
+ unless File.exist?(file_path)
26
+ return fail_message(message, "attachment_missing",
27
+ "The file to send could not be read.")
28
+ end
29
+
30
+ limit = SIZE_LIMITS.fetch(type, SIZE_LIMITS["file"])
31
+ if File.size(file_path) > limit
32
+ return fail_message(message, "attachment_too_large",
33
+ "Instagram refuses #{type}s over #{limit / 1.megabyte}MB.")
34
+ end
35
+
36
+ upload(message, file_path, type)
37
+ end
38
+
39
+ private
40
+
41
+ def upload(message, file_path, type)
42
+ result = File.open(file_path) do |file|
43
+ message.conversation.account.client.upload_attachment(file: file, type: type)
44
+ end
45
+
46
+ unless result.success?
47
+ return fail_message(message, result.error_code&.to_s, result.error_message)
48
+ end
49
+
50
+ attachment_id = result.data["attachment_id"].presence || result.id
51
+ if attachment_id.blank?
52
+ return fail_message(message, "attachment_upload_empty",
53
+ "Instagram accepted the upload but returned no attachment id.")
54
+ end
55
+
56
+ message.update!(attachment_upload_id: attachment_id, media_kind: type)
57
+ SendMessageJob.perform_later(message.id)
58
+ end
59
+
60
+ def fail_message(message, reason, error)
61
+ message.update!(status: "failed", failure_reason: reason, error_message: error)
62
+ message.broadcast_refresh
63
+ nil
64
+ end
65
+ end
66
+ end
@@ -42,6 +42,12 @@ module InstagramConnect
42
42
  sent_at || created_at
43
43
  end
44
44
 
45
+ # The attachment half of this send is done. Mirrors delivered_parts_count
46
+ # for the text half, so a resumed job never re-sends the photo.
47
+ def attachment_delivered?
48
+ attachment_delivered_at.present?
49
+ end
50
+
45
51
  def deleted?
46
52
  deleted_at.present?
47
53
  end
@@ -0,0 +1,9 @@
1
+ class AddAttachmentDeliveredAtToMessages < ActiveRecord::Migration[8.0]
2
+ # The resume cursor for the attachment half of a send, mirroring
3
+ # delivered_parts_count for the text half: a worker killed between the
4
+ # attachment and the text must not send the photo twice.
5
+ def change
6
+ add_column :instagram_connect_messages, :attachment_delivered_at, :datetime,
7
+ if_not_exists: true
8
+ end
9
+ end
@@ -15,6 +15,17 @@ module InstagramConnect
15
15
  end
16
16
 
17
17
  def call
18
+ # An echo of a message this system already sent is a CONFIRMATION,
19
+ # not a new message: the send stored the mid first, so create! hits
20
+ # the unique index on [account_id, ig_message_id] and the event is
21
+ # banked failed — then every replay fails again, forever. Enrich the
22
+ # row Meta is echoing rather than duplicating it, and do not
23
+ # re-register it on the conversation (it was registered when sent).
24
+ if (existing = existing_message)
25
+ enrich(existing)
26
+ return existing
27
+ end
28
+
18
29
  message = InstagramConnect::Message.create!(
19
30
  conversation: conversation,
20
31
  direction: echo? ? "outbound" : "inbound",
@@ -49,6 +60,28 @@ module InstagramConnect
49
60
 
50
61
  private
51
62
 
63
+ def existing_message
64
+ return if mid.blank?
65
+
66
+ InstagramConnect::Message.find_by(account_id: conversation.account_id,
67
+ ig_message_id: mid)
68
+ end
69
+
70
+ # Fill only what the original write could not know — Meta's clock, the
71
+ # media it processed, the story it was a reply to. Direction, status,
72
+ # source and body stay as first written: an echo must not rewrite a
73
+ # CMS-sent message into one that looks sent from the phone.
74
+ def enrich(message)
75
+ message.sent_at ||= envelope.occurred_at
76
+ message.media_status ||= media_status
77
+ message.media_kind ||= attachments.first&.dig("type")
78
+ message.story_id ||= story&.dig("id")
79
+ message.story_url ||= story&.dig("url")
80
+ message.save! if message.changed?
81
+ store_attachments(message) if message.attachments.none?
82
+ message
83
+ end
84
+
52
85
  def payload
53
86
  event["message"] || {}
54
87
  end
@@ -1,3 +1,3 @@
1
1
  module InstagramConnect
2
- VERSION = "0.3.13"
2
+ VERSION = "0.3.14"
3
3
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: instagram_connect
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.13
4
+ version: 0.3.14
5
5
  platform: ruby
6
6
  authors:
7
7
  - Kshitiz Sinha
@@ -104,6 +104,7 @@ files:
104
104
  - app/jobs/instagram_connect/sync_conversations_job.rb
105
105
  - app/jobs/instagram_connect/sync_media_job.rb
106
106
  - app/jobs/instagram_connect/sync_stories_job.rb
107
+ - app/jobs/instagram_connect/upload_attachment_job.rb
107
108
  - app/models/instagram_connect/account.rb
108
109
  - app/models/instagram_connect/api_budget.rb
109
110
  - app/models/instagram_connect/application_record.rb
@@ -148,6 +149,7 @@ files:
148
149
  - db/migrate/20260725205417_add_conversation_profile_fields.rb
149
150
  - db/migrate/20260727074500_add_removed_flag_to_instagram_connect_media.rb
150
151
  - db/migrate/20260727110000_add_children_to_instagram_connect_media.rb
152
+ - db/migrate/20260728020000_add_attachment_delivered_at_to_messages.rb
151
153
  - docs/CONFIGURATION.md
152
154
  - docs/app_review_guide.md
153
155
  - docs/auth_paths.md