portage-ucp-instagram 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/CHANGELOG.md +11 -0
- data/LICENSE +21 -0
- data/README.md +112 -0
- data/lib/portage/ucp/instagram/access_token_fetcher.rb +41 -0
- data/lib/portage/ucp/instagram/adapter.rb +118 -0
- data/lib/portage/ucp/instagram/client.rb +35 -0
- data/lib/portage/ucp/instagram/errors.rb +24 -0
- data/lib/portage/ucp/instagram/mapper.rb +146 -0
- data/lib/portage/ucp/instagram/version.rb +7 -0
- data/lib/portage/ucp/instagram.rb +7 -0
- metadata +130 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: f6de3c1bfd185403eb82d68015bea9a509ffe1611fcf438db56b66b40848d8c3
|
|
4
|
+
data.tar.gz: 859258fcf4198304fcc7a4fbd31c98e72f20db77711c8000f0ee14a13d40b3e9
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: ad5a5195f6e873cca743a6eb9d5227fe799f35ca3a140389d37bbcc9726201f0a8bf39728599f31b611144ee7f41f135aaa3c713fe6214704cbfe561233ef2bf
|
|
7
|
+
data.tar.gz: 2ed79e836dd1227ec848a529a8e2fca6c7f2558e35eaaa149945675db8d12e6c63252d1d6bc55ac1b51771dd9e4309e84d73bccc4ae85015d8cf00699c65a47a
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project are documented here. Format loosely follows
|
|
4
|
+
[Keep a Changelog](https://keepachangelog.com/en/1.0.0/); this project is
|
|
5
|
+
pre-1.0, so APIs may still shift between minor versions.
|
|
6
|
+
|
|
7
|
+
## [0.1.0] - Unreleased
|
|
8
|
+
|
|
9
|
+
- Initial pre-release. Instagram/Facebook Shops adapter against Meta's Graph
|
|
10
|
+
API Commerce Catalog — catalog real, checkout redirect-link only, order
|
|
11
|
+
lookup limited to "checkout on Instagram/Facebook" merchants.
|
data/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Tom Whitbread
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
# portage-ucp-instagram
|
|
2
|
+
|
|
3
|
+
Instagram/Facebook Shops adapter for [`portage-ucp`](../portage-ucp). Implements `Portage::Ucp::Adapter` against Meta's Graph API Commerce Catalog. Generic only — no merchant-specific business logic. Plain `Net::HTTP`, no Facebook SDK runtime dependency.
|
|
4
|
+
|
|
5
|
+
## What it covers — and what it deliberately doesn't
|
|
6
|
+
|
|
7
|
+
Like [`portage-ucp-etsy`](../portage-ucp-etsy), this is **catalog + redirect-link checkout + order**, not a full transactional adapter — for a more fundamental reason than Etsy. Instagram/Facebook Shops splits into two merchant populations:
|
|
8
|
+
|
|
9
|
+
- **"Checkout on your website"** — each catalog product carries its own merchant-hosted `url`. Buying happens entirely on the merchant's own site, never through Meta. This is the population `create_checkout` is built for: it redirects to that `url`, same posture as Etsy's listing-page redirect.
|
|
10
|
+
- **"Checkout on Instagram/Facebook"** — buying happens natively inside the Meta app, with **no exposed URL or API to drive it at all** — not even a redirect is possible here. Meta Commerce Orders from *this* population are the only ones `get_order` can ever see; this adapter can't originate a purchase for them, only read one back after the fact.
|
|
11
|
+
|
|
12
|
+
| UCP capability | Backing Graph API | Notes |
|
|
13
|
+
|---|---|---|
|
|
14
|
+
| `dev.ucp.shopping.catalog` | Commerce Catalog | `search_catalog`, `get_product` |
|
|
15
|
+
| `dev.ucp.shopping.checkout` | — | `create_checkout`/`get_checkout` only, redirect-link, "checkout on your website" catalogs only. `update_checkout`/`complete_checkout`/`cancel_checkout` raise `Portage::Ucp::NotImplementedError` — nothing to call. |
|
|
16
|
+
| `dev.ucp.shopping.order` | Commerce Orders | `get_order` — only returns data for "checkout on Instagram/Facebook" merchants; 403/404s for everyone else, since their orders live entirely in their own system |
|
|
17
|
+
| `dev.ucp.shopping.cart` | — | not implemented; no cart resource exists |
|
|
18
|
+
| `dev.ucp.shopping.identity` | — | not implemented; Instagram/Facebook user login is a separate concern from the Page/catalog token used here |
|
|
19
|
+
|
|
20
|
+
Same as Etsy: `create_checkout`'s Checkout objects are **not real Meta resources** — they live only in the `Adapter` instance's memory (`get_checkout` reads back what `create_checkout` stored), not surviving a process restart. `get_order`'s `checkout_id` is always blank for the same reason.
|
|
21
|
+
|
|
22
|
+
## Installation
|
|
23
|
+
|
|
24
|
+
```ruby
|
|
25
|
+
# Gemfile
|
|
26
|
+
gem "portage-ucp-instagram"
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
bundle install
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
## Setup
|
|
34
|
+
|
|
35
|
+
You need a long-lived Page/catalog access token and your Commerce Catalog id.
|
|
36
|
+
|
|
37
|
+
```ruby
|
|
38
|
+
require "portage/ucp/instagram"
|
|
39
|
+
|
|
40
|
+
client = Portage::Ucp::Instagram::Client.new(access_token: ENV.fetch("INSTAGRAM_ACCESS_TOKEN"))
|
|
41
|
+
adapter = Portage::Ucp::Instagram::Adapter.new(client: client, catalog_id: ENV.fetch("INSTAGRAM_CATALOG_ID"))
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
### Getting a long-lived access token
|
|
45
|
+
|
|
46
|
+
The initial short-lived token comes from Meta's interactive Business Login consent flow (outside this gem's scope). Exchange it for a long-lived one (~60 days):
|
|
47
|
+
|
|
48
|
+
```ruby
|
|
49
|
+
fetcher = Portage::Ucp::Instagram::AccessTokenFetcher.new(
|
|
50
|
+
client_id: ENV.fetch("INSTAGRAM_CLIENT_ID"),
|
|
51
|
+
client_secret: ENV.fetch("INSTAGRAM_CLIENT_SECRET"),
|
|
52
|
+
short_lived_token: ENV.fetch("INSTAGRAM_SHORT_LIVED_TOKEN")
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
result = fetcher.fetch
|
|
56
|
+
result.access_token # => pass into Client.new
|
|
57
|
+
result.expires_in # => ~5,184,000 seconds (60 days) — re-run Business Login after that, no refresh grant exists
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
## Usage
|
|
61
|
+
|
|
62
|
+
```ruby
|
|
63
|
+
# Catalog — product_id is the Graph API product node id
|
|
64
|
+
products = adapter.search_catalog(query: "mug", limit: 10)
|
|
65
|
+
product = adapter.get_product(product_id: products.first.id)
|
|
66
|
+
|
|
67
|
+
# Checkout — a redirect, not a real transaction
|
|
68
|
+
checkout = adapter.create_checkout(
|
|
69
|
+
line_items: [{ product_id: product.variants.first[:id], quantity: 1 }],
|
|
70
|
+
idempotency_key: SecureRandom.uuid
|
|
71
|
+
)
|
|
72
|
+
checkout.links.first.url # => hand this to the shopper/agent to complete the purchase on the merchant's site
|
|
73
|
+
|
|
74
|
+
# Order — only works for "checkout on Instagram/Facebook" merchants
|
|
75
|
+
order = adapter.get_order(order_id: some_commerce_order_id)
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
## Wiring into portage-ucp
|
|
79
|
+
|
|
80
|
+
Drop the adapter into a `Dispatcher` (or the MCP server) the same as any other backend:
|
|
81
|
+
|
|
82
|
+
```ruby
|
|
83
|
+
dispatcher = Portage::Ucp::Dispatcher.new(adapter: adapter)
|
|
84
|
+
|
|
85
|
+
dispatcher.call(
|
|
86
|
+
capability: "dev.ucp.shopping.checkout",
|
|
87
|
+
action: "create_checkout",
|
|
88
|
+
arguments: { line_items: [{ product_id: product_node_id, quantity: 1 }], idempotency_key: SecureRandom.uuid }
|
|
89
|
+
)
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
## Errors
|
|
93
|
+
|
|
94
|
+
```ruby
|
|
95
|
+
Portage::Ucp::Instagram::Error # base class
|
|
96
|
+
Portage::Ucp::Instagram::ApiError # any non-2xx response from Meta's Graph API
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
## Development
|
|
100
|
+
|
|
101
|
+
```bash
|
|
102
|
+
bundle exec rspec # tests (WebMock-stubbed, no live Meta account needed)
|
|
103
|
+
bundle exec rubocop # lint
|
|
104
|
+
|
|
105
|
+
# exchange a real short-lived token for a long-lived one
|
|
106
|
+
INSTAGRAM_CLIENT_ID=... INSTAGRAM_CLIENT_SECRET=... INSTAGRAM_SHORT_LIVED_TOKEN=... \
|
|
107
|
+
bundle exec rake instagram_access_token
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
## License
|
|
111
|
+
|
|
112
|
+
[MIT](LICENSE) — Copyright (c) 2026 Tom Whitbread.
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
require "net/http"
|
|
2
|
+
require "json"
|
|
3
|
+
|
|
4
|
+
module Portage
|
|
5
|
+
module Ucp
|
|
6
|
+
module Instagram
|
|
7
|
+
# Exchanges a short-lived user/page access token for a long-lived one
|
|
8
|
+
# (~60 days) via Graph API's `fb_exchange_token` grant.
|
|
9
|
+
#
|
|
10
|
+
# Doesn't handle the *initial* Business Login consent redirect that
|
|
11
|
+
# produces the short-lived token in the first place (that's an
|
|
12
|
+
# interactive, one-time setup step outside this gem's scope) — only
|
|
13
|
+
# the long-lived exchange a server needs to avoid re-running consent
|
|
14
|
+
# every few hours. Meta doesn't support refreshing past the ~60-day
|
|
15
|
+
# long-lived token's own expiry — re-running consent is the only way
|
|
16
|
+
# past that, there's no refresh_token here the way Etsy/Shopify have.
|
|
17
|
+
class AccessTokenFetcher
|
|
18
|
+
Result = Struct.new(:access_token, :expires_in, keyword_init: true)
|
|
19
|
+
|
|
20
|
+
def initialize(client_id:, client_secret:, short_lived_token:, api_version: Client::DEFAULT_API_VERSION)
|
|
21
|
+
@client_id = client_id
|
|
22
|
+
@client_secret = client_secret
|
|
23
|
+
@short_lived_token = short_lived_token
|
|
24
|
+
@api_version = api_version
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def fetch
|
|
28
|
+
params = { grant_type: "fb_exchange_token", client_id: @client_id, client_secret: @client_secret,
|
|
29
|
+
fb_exchange_token: @short_lived_token }
|
|
30
|
+
uri = URI("https://graph.facebook.com/#{@api_version}/oauth/access_token?#{URI.encode_www_form(params)}")
|
|
31
|
+
|
|
32
|
+
response = Net::HTTP.start(uri.host, uri.port, use_ssl: true) { |http| http.request(Net::HTTP::Get.new(uri)) }
|
|
33
|
+
body = JSON.parse(response.body)
|
|
34
|
+
raise Portage::Ucp::Instagram::Error, "token exchange failed: #{body}" unless response.is_a?(Net::HTTPSuccess)
|
|
35
|
+
|
|
36
|
+
Result.new(access_token: body["access_token"], expires_in: body["expires_in"])
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
require "json"
|
|
2
|
+
require "uri"
|
|
3
|
+
|
|
4
|
+
module Portage
|
|
5
|
+
module Ucp
|
|
6
|
+
module Instagram
|
|
7
|
+
# Generic Portage::Ucp::Adapter over Meta's Graph API Commerce Catalog
|
|
8
|
+
# — no merchant-specific business logic, same posture as the other
|
|
9
|
+
# adapters in this project.
|
|
10
|
+
#
|
|
11
|
+
# IMPORTANT: like Portage::Ucp::Etsy::Adapter, this is deliberately a
|
|
12
|
+
# **catalog + redirect-link checkout + order** adapter, not a full
|
|
13
|
+
# transactional one — and for a more fundamental reason than Etsy.
|
|
14
|
+
# Instagram/Facebook Shops split into two populations:
|
|
15
|
+
#
|
|
16
|
+
# - "Checkout on your website" catalogs: each product carries its own
|
|
17
|
+
# merchant-hosted `url`. Buying happens entirely on the merchant's
|
|
18
|
+
# own site, not through Meta at all — this is the case this
|
|
19
|
+
# adapter's `create_checkout` is built for, redirecting to that
|
|
20
|
+
# `url` per product, same posture as Etsy's listing-page redirect.
|
|
21
|
+
# - "Checkout on Instagram/Facebook" catalogs: buying happens
|
|
22
|
+
# natively inside the Meta app, with **no exposed URL or API to
|
|
23
|
+
# drive it** at all — not even a redirect is possible here. Meta
|
|
24
|
+
# orders from *this* population are the only ones `get_order` can
|
|
25
|
+
# ever see (see Portage::Ucp::Instagram::Mapper.order's comment);
|
|
26
|
+
# this adapter can't originate a purchase for them, only read one
|
|
27
|
+
# back after the fact.
|
|
28
|
+
#
|
|
29
|
+
# Same as Etsy: doesn't override `get_cart`/`create_cart`/
|
|
30
|
+
# `update_cart`/`cancel_cart` (no cart resource exists),
|
|
31
|
+
# `update_checkout`/`complete_checkout`/`cancel_checkout` (nothing to
|
|
32
|
+
# update/complete/cancel programmatically — calling them raises
|
|
33
|
+
# Portage::Ucp::NotImplementedError), or `link_identity` (Instagram/
|
|
34
|
+
# Facebook user login is a separate concern from the Page/catalog
|
|
35
|
+
# token used here). `create_checkout`'s Checkout objects are **not
|
|
36
|
+
# real Meta resources** — they live only in this Adapter instance's
|
|
37
|
+
# memory, same as Etsy's.
|
|
38
|
+
class Adapter < Portage::Ucp::Adapter
|
|
39
|
+
PRODUCT_FIELDS = "id,name,description,price,availability,url,item_group_id".freeze
|
|
40
|
+
|
|
41
|
+
# §9a dedup via Support::Idempotency, so an agent's retry on a
|
|
42
|
+
# dropped connection doesn't build a second, different in-memory
|
|
43
|
+
# Checkout for the same intent.
|
|
44
|
+
include Portage::Ucp::Support::Idempotency
|
|
45
|
+
|
|
46
|
+
def initialize(client:, catalog_id:)
|
|
47
|
+
super()
|
|
48
|
+
@client = client
|
|
49
|
+
@catalog_id = catalog_id
|
|
50
|
+
@checkouts = {}
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def search_catalog(query:, limit:)
|
|
54
|
+
filter = URI.encode_www_form_component(JSON.generate(name: { i_contains: query }))
|
|
55
|
+
data = @client.get("/#{@catalog_id}/products?fields=#{PRODUCT_FIELDS}&limit=#{limit}&filter=#{filter}")
|
|
56
|
+
(data["data"] || []).map { |node| Mapper.product(node) }
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def get_product(product_id:)
|
|
60
|
+
node = @client.get("/#{product_id}?fields=#{PRODUCT_FIELDS}")
|
|
61
|
+
return nil unless node["id"]
|
|
62
|
+
|
|
63
|
+
Mapper.product(with_variants(node))
|
|
64
|
+
rescue Portage::Ucp::Instagram::ApiError => e
|
|
65
|
+
raise unless [400, 404].include?(e.status)
|
|
66
|
+
|
|
67
|
+
nil
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def create_checkout(line_items:, idempotency_key:)
|
|
71
|
+
dedup(idempotency_key) do
|
|
72
|
+
products = line_items.map { |li| product_with_quantity(li) }
|
|
73
|
+
checkout_id = "instagram-checkout-#{idempotency_key}"
|
|
74
|
+
checkout = Mapper.checkout(products, id: checkout_id, status: "incomplete")
|
|
75
|
+
@checkouts[checkout_id] = checkout
|
|
76
|
+
checkout
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def get_checkout(checkout_id:)
|
|
81
|
+
@checkouts[checkout_id]
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# See the class-level comment: this only ever returns data for
|
|
85
|
+
# "Checkout on Instagram/Facebook" merchants — everyone else's
|
|
86
|
+
# orders live entirely outside Meta's system.
|
|
87
|
+
def get_order(order_id:)
|
|
88
|
+
fields = "id,order_status,items{retailer_id,product_name,quantity,price_per_unit}," \
|
|
89
|
+
"estimated_payment_details"
|
|
90
|
+
node = @client.get("/#{order_id}?fields=#{fields}")
|
|
91
|
+
node["id"] ? Mapper.order(node) : nil
|
|
92
|
+
rescue Portage::Ucp::Instagram::ApiError => e
|
|
93
|
+
raise unless [400, 403, 404].include?(e.status)
|
|
94
|
+
|
|
95
|
+
nil
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
private
|
|
99
|
+
|
|
100
|
+
# A product's own resource only carries its `item_group_id`, not
|
|
101
|
+
# its siblings — fetching the rest of the variant group is a second
|
|
102
|
+
# call, only made for #get_product's single-product path, same N+1
|
|
103
|
+
# reasoning as every other adapter's variant fetch.
|
|
104
|
+
def with_variants(node)
|
|
105
|
+
return node unless node["item_group_id"]
|
|
106
|
+
|
|
107
|
+
filter = URI.encode_www_form_component(JSON.generate(item_group_id: { eq: node["item_group_id"] }))
|
|
108
|
+
data = @client.get("/#{@catalog_id}/products?fields=id,name,availability,price&filter=#{filter}")
|
|
109
|
+
node.merge("variants_detail" => data["data"])
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def product_with_quantity(line_item)
|
|
113
|
+
@client.get("/#{line_item[:product_id]}?fields=id,name,price,url").merge("quantity" => line_item[:quantity])
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
end
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
require "net/http"
|
|
2
|
+
require "json"
|
|
3
|
+
|
|
4
|
+
module Portage
|
|
5
|
+
module Ucp
|
|
6
|
+
module Instagram
|
|
7
|
+
# Minimal REST client over Meta's Graph API
|
|
8
|
+
# (`graph.facebook.com/{api_version}`), used for both Instagram and
|
|
9
|
+
# Facebook Shops — they share one Commerce Catalog underneath.
|
|
10
|
+
#
|
|
11
|
+
# Deliberately plain Net::HTTP, not the `koala`/`facebook-ads-sdk`
|
|
12
|
+
# gems — a generic adapter that any Ruby app can drop in only needs a
|
|
13
|
+
# base URL and a bearer token, trivially stubbable with WebMock.
|
|
14
|
+
class Client
|
|
15
|
+
include Portage::Ucp::Support::HttpClient
|
|
16
|
+
|
|
17
|
+
DEFAULT_API_VERSION = "v21.0".freeze
|
|
18
|
+
|
|
19
|
+
def initialize(access_token:, api_version: DEFAULT_API_VERSION)
|
|
20
|
+
@access_token = access_token
|
|
21
|
+
@api_version = api_version
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def get(path)
|
|
25
|
+
json_request(Net::HTTP::Get, "https://graph.facebook.com/#{@api_version}#{path}",
|
|
26
|
+
headers: { "Authorization" => "Bearer #{@access_token}" })
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
private
|
|
30
|
+
|
|
31
|
+
def api_error_class = Portage::Ucp::Instagram::ApiError
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
module Portage
|
|
2
|
+
module Ucp
|
|
3
|
+
module Instagram
|
|
4
|
+
class Error < StandardError; end
|
|
5
|
+
|
|
6
|
+
# Raised for any non-2xx response from Meta's Graph API
|
|
7
|
+
# (`graph.facebook.com`) — a non-2xx status with a JSON
|
|
8
|
+
# `{error: {message, type, code}}` body.
|
|
9
|
+
class ApiError < Error
|
|
10
|
+
include Portage::Ucp::Support::ApiError
|
|
11
|
+
|
|
12
|
+
private
|
|
13
|
+
|
|
14
|
+
# Not just "Instagram": the same client and errors cover Facebook
|
|
15
|
+
# Shops, since both sit on one Meta Graph API surface.
|
|
16
|
+
def api_label = "Instagram/Graph"
|
|
17
|
+
|
|
18
|
+
def detail(body)
|
|
19
|
+
body.dig("error", "message") || body
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
module Portage
|
|
2
|
+
module Ucp
|
|
3
|
+
module Instagram
|
|
4
|
+
# Converts Meta Graph API Commerce Catalog/Orders response bodies
|
|
5
|
+
# into the protocol-layer value objects from
|
|
6
|
+
# Portage::Ucp::ValueObjects — nothing Meta-shaped is allowed to leak
|
|
7
|
+
# past this file.
|
|
8
|
+
module Mapper
|
|
9
|
+
module_function
|
|
10
|
+
|
|
11
|
+
AVAILABLE_STATES = ["in stock", "available for order", "preorder"].freeze
|
|
12
|
+
|
|
13
|
+
# Meta's Catalog product `price` field is a single string combining
|
|
14
|
+
# amount and currency (`"25.00 USD"`), unlike every REST-based
|
|
15
|
+
# adapter in this project splitting those into separate fields —
|
|
16
|
+
# this is the one place that string needs parsing.
|
|
17
|
+
def money(price_string)
|
|
18
|
+
return Portage::Ucp::Money.new(amount_minor: 0, currency: nil) unless price_string
|
|
19
|
+
|
|
20
|
+
amount, currency = price_string.split
|
|
21
|
+
money_from_parts(amount, currency)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Unlike the catalog Product's combined `"25.00 USD"` price
|
|
25
|
+
# string, the Commerce Orders API's amounts come as separate
|
|
26
|
+
# `amount`/`currency` fields — this is the shape #order and
|
|
27
|
+
# #order_line_item work with.
|
|
28
|
+
def money_from_parts(amount, currency)
|
|
29
|
+
Portage::Ucp::Support::Amounts.money(amount, currency)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# `node["variants_detail"]` is adapter-populated, not a real Meta
|
|
33
|
+
# field: variants of a catalog product are just *other whole
|
|
34
|
+
# product nodes* sharing the same `item_group_id` — there's no
|
|
35
|
+
# nested variant array to read off a single product the way
|
|
36
|
+
# Shopify/BigCommerce have one. Fetching the group's other members
|
|
37
|
+
# is a second call, made only for #get_product's single-product
|
|
38
|
+
# path, same N+1 reasoning as every other adapter's variant fetch.
|
|
39
|
+
def product(node, site_url: nil)
|
|
40
|
+
Portage::Ucp::Product.new(
|
|
41
|
+
id: node["id"],
|
|
42
|
+
title: node["name"],
|
|
43
|
+
description: node["description"],
|
|
44
|
+
price: money(node["price"]),
|
|
45
|
+
available: AVAILABLE_STATES.include?(node["availability"]),
|
|
46
|
+
variants: variants(node),
|
|
47
|
+
url: node["url"] || site_url
|
|
48
|
+
)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# A product with no `item_group_id` siblings is its own single
|
|
52
|
+
# implicit variant, same as a single-variant Shopify product using
|
|
53
|
+
# that variant's own id.
|
|
54
|
+
def variants(node)
|
|
55
|
+
detail = node["variants_detail"]
|
|
56
|
+
return [variant(node)] unless detail
|
|
57
|
+
|
|
58
|
+
detail.map { |v| variant(v) }
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def variant(node)
|
|
62
|
+
{ id: node["id"], title: node["name"], available: AVAILABLE_STATES.include?(node["availability"]),
|
|
63
|
+
price: money(node["price"]) }
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# `id:` is caller-supplied: there's no real Meta checkout resource
|
|
67
|
+
# behind this at all for "checkout on your website" catalogs — see
|
|
68
|
+
# Portage::Ucp::Instagram::Adapter's class-level comment. `links`
|
|
69
|
+
# points at each product's own `url` (the merchant's own product
|
|
70
|
+
# page) rather than a cart, since Instagram/Facebook's public API
|
|
71
|
+
# has no way to deep-link a multi-item add-to-cart flow outside
|
|
72
|
+
# Meta's own native checkout.
|
|
73
|
+
def checkout(products, id:, status:)
|
|
74
|
+
line_items = products.map { |p| checkout_line_item(p) }
|
|
75
|
+
Portage::Ucp::Checkout.new(
|
|
76
|
+
id: id,
|
|
77
|
+
status: status,
|
|
78
|
+
line_items: line_items,
|
|
79
|
+
currency: products.first && money(products.first["price"]).currency,
|
|
80
|
+
totals: totals(products),
|
|
81
|
+
links: products.map { |p| Portage::Ucp::Link.new(type: "checkout", url: p["url"], title: p["name"]) }
|
|
82
|
+
)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def checkout_line_item(node)
|
|
86
|
+
unit_price = money(node["price"]).amount_minor
|
|
87
|
+
quantity = node["quantity"] || 1
|
|
88
|
+
line_total = unit_price * quantity
|
|
89
|
+
Portage::Ucp::LineItem.new(
|
|
90
|
+
id: node["id"],
|
|
91
|
+
item: Portage::Ucp::Item.new(id: node["id"], title: node["name"], price: unit_price),
|
|
92
|
+
quantity: quantity,
|
|
93
|
+
totals: Portage::Ucp::Support::Totals.line(line_total)
|
|
94
|
+
)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def totals(products)
|
|
98
|
+
subtotal = products.sum { |p| money(p["price"]).amount_minor * (p["quantity"] || 1) }
|
|
99
|
+
Portage::Ucp::Support::Totals.summary(subtotal: subtotal, total: subtotal)
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# Meta's Commerce Order resource only exists at all for "Checkout on
|
|
103
|
+
# Instagram/Facebook" merchants — merchants using "checkout on your
|
|
104
|
+
# website" (the population Checkout#links above is built for) never
|
|
105
|
+
# have a Meta-side order; their orders live entirely in their own
|
|
106
|
+
# system (e.g. via the Shopify/WooCommerce/etc adapter for that
|
|
107
|
+
# side), and this method will 403/404 for them. `permalink_url` is
|
|
108
|
+
# left blank — Meta doesn't return a buyer-facing order link via
|
|
109
|
+
# this API. `checkout_id` is always blank too, same fundamental
|
|
110
|
+
# reason as Portage::Ucp::Etsy::Mapper.order.
|
|
111
|
+
ORDER_STATUS = { "COMPLETED" => "fulfilled", "CANCELLED" => "removed" }.freeze
|
|
112
|
+
|
|
113
|
+
def order(node)
|
|
114
|
+
status = Portage::Ucp::Support::LineItemStatus.from_table(ORDER_STATUS, node.dig("order_status", "state"))
|
|
115
|
+
items = node.dig("items", "data") || []
|
|
116
|
+
subtotal = money_from_parts(node.dig("estimated_payment_details", "subtotal", "amount"), nil).amount_minor
|
|
117
|
+
total = money_from_parts(node.dig("estimated_payment_details", "total_amount", "amount"), nil).amount_minor
|
|
118
|
+
Portage::Ucp::Order.new(
|
|
119
|
+
id: node["id"],
|
|
120
|
+
checkout_id: "",
|
|
121
|
+
permalink_url: "",
|
|
122
|
+
line_items: items.map { |n| order_line_item(n, status) },
|
|
123
|
+
fulfillment: Portage::Ucp::Fulfillment.new,
|
|
124
|
+
currency: node.dig("estimated_payment_details", "total_amount", "currency"),
|
|
125
|
+
totals: Portage::Ucp::Support::Totals.summary(subtotal: subtotal, total: total)
|
|
126
|
+
)
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def order_line_item(node, status)
|
|
130
|
+
quantity = node["quantity"]
|
|
131
|
+
fulfilled = Portage::Ucp::Support::LineItemStatus.fulfilled_quantity(status, quantity)
|
|
132
|
+
unit_price = money_from_parts(node.dig("price_per_unit", "amount"), nil).amount_minor
|
|
133
|
+
line_total = unit_price * quantity
|
|
134
|
+
Portage::Ucp::OrderLineItem.new(
|
|
135
|
+
id: node["id"].to_s,
|
|
136
|
+
item: Portage::Ucp::Item.new(id: node["retailer_id"].to_s, title: node["product_name"],
|
|
137
|
+
price: unit_price),
|
|
138
|
+
quantity: { original: quantity, total: quantity, fulfilled: fulfilled },
|
|
139
|
+
totals: Portage::Ucp::Support::Totals.line(line_total),
|
|
140
|
+
status: status
|
|
141
|
+
)
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: portage-ucp-instagram
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Tom Whitbread
|
|
8
|
+
autorequire:
|
|
9
|
+
bindir: exe
|
|
10
|
+
cert_chain: []
|
|
11
|
+
date: 2026-08-14 00:00:00.000000000 Z
|
|
12
|
+
dependencies:
|
|
13
|
+
- !ruby/object:Gem::Dependency
|
|
14
|
+
name: portage-ucp
|
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
|
16
|
+
requirements:
|
|
17
|
+
- - "~>"
|
|
18
|
+
- !ruby/object:Gem::Version
|
|
19
|
+
version: '0.1'
|
|
20
|
+
type: :runtime
|
|
21
|
+
prerelease: false
|
|
22
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
23
|
+
requirements:
|
|
24
|
+
- - "~>"
|
|
25
|
+
- !ruby/object:Gem::Version
|
|
26
|
+
version: '0.1'
|
|
27
|
+
- !ruby/object:Gem::Dependency
|
|
28
|
+
name: rspec
|
|
29
|
+
requirement: !ruby/object:Gem::Requirement
|
|
30
|
+
requirements:
|
|
31
|
+
- - "~>"
|
|
32
|
+
- !ruby/object:Gem::Version
|
|
33
|
+
version: '3.13'
|
|
34
|
+
type: :development
|
|
35
|
+
prerelease: false
|
|
36
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
37
|
+
requirements:
|
|
38
|
+
- - "~>"
|
|
39
|
+
- !ruby/object:Gem::Version
|
|
40
|
+
version: '3.13'
|
|
41
|
+
- !ruby/object:Gem::Dependency
|
|
42
|
+
name: rubocop
|
|
43
|
+
requirement: !ruby/object:Gem::Requirement
|
|
44
|
+
requirements:
|
|
45
|
+
- - "~>"
|
|
46
|
+
- !ruby/object:Gem::Version
|
|
47
|
+
version: '1.88'
|
|
48
|
+
type: :development
|
|
49
|
+
prerelease: false
|
|
50
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
51
|
+
requirements:
|
|
52
|
+
- - "~>"
|
|
53
|
+
- !ruby/object:Gem::Version
|
|
54
|
+
version: '1.88'
|
|
55
|
+
- !ruby/object:Gem::Dependency
|
|
56
|
+
name: webmock
|
|
57
|
+
requirement: !ruby/object:Gem::Requirement
|
|
58
|
+
requirements:
|
|
59
|
+
- - "~>"
|
|
60
|
+
- !ruby/object:Gem::Version
|
|
61
|
+
version: '3.24'
|
|
62
|
+
type: :development
|
|
63
|
+
prerelease: false
|
|
64
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
65
|
+
requirements:
|
|
66
|
+
- - "~>"
|
|
67
|
+
- !ruby/object:Gem::Version
|
|
68
|
+
version: '3.24'
|
|
69
|
+
- !ruby/object:Gem::Dependency
|
|
70
|
+
name: yard
|
|
71
|
+
requirement: !ruby/object:Gem::Requirement
|
|
72
|
+
requirements:
|
|
73
|
+
- - "~>"
|
|
74
|
+
- !ruby/object:Gem::Version
|
|
75
|
+
version: '0.9'
|
|
76
|
+
type: :development
|
|
77
|
+
prerelease: false
|
|
78
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
79
|
+
requirements:
|
|
80
|
+
- - "~>"
|
|
81
|
+
- !ruby/object:Gem::Version
|
|
82
|
+
version: '0.9'
|
|
83
|
+
description: Implements Portage::Ucp::Adapter against Meta's Graph API Commerce Catalog.
|
|
84
|
+
Catalog is real; checkout is redirect-link only (to each product's own merchant-hosted
|
|
85
|
+
URL) since Meta's public API has no cart/checkout endpoint for website-checkout
|
|
86
|
+
catalogs, and none at all for native Instagram/Facebook checkout. Generic only —
|
|
87
|
+
no merchant-specific business logic. Plain Net::HTTP, no Facebook SDK runtime dependency.
|
|
88
|
+
email:
|
|
89
|
+
executables: []
|
|
90
|
+
extensions: []
|
|
91
|
+
extra_rdoc_files: []
|
|
92
|
+
files:
|
|
93
|
+
- CHANGELOG.md
|
|
94
|
+
- LICENSE
|
|
95
|
+
- README.md
|
|
96
|
+
- lib/portage/ucp/instagram.rb
|
|
97
|
+
- lib/portage/ucp/instagram/access_token_fetcher.rb
|
|
98
|
+
- lib/portage/ucp/instagram/adapter.rb
|
|
99
|
+
- lib/portage/ucp/instagram/client.rb
|
|
100
|
+
- lib/portage/ucp/instagram/errors.rb
|
|
101
|
+
- lib/portage/ucp/instagram/mapper.rb
|
|
102
|
+
- lib/portage/ucp/instagram/version.rb
|
|
103
|
+
homepage: https://github.com/tomtom87/Portage/tree/main/portage-ucp-instagram
|
|
104
|
+
licenses:
|
|
105
|
+
- MIT
|
|
106
|
+
metadata:
|
|
107
|
+
source_code_uri: https://github.com/tomtom87/Portage/tree/main/portage-ucp-instagram
|
|
108
|
+
changelog_uri: https://github.com/tomtom87/Portage/blob/main/portage-ucp-instagram/CHANGELOG.md
|
|
109
|
+
rubygems_mfa_required: 'true'
|
|
110
|
+
post_install_message:
|
|
111
|
+
rdoc_options: []
|
|
112
|
+
require_paths:
|
|
113
|
+
- lib
|
|
114
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
115
|
+
requirements:
|
|
116
|
+
- - ">="
|
|
117
|
+
- !ruby/object:Gem::Version
|
|
118
|
+
version: '3.2'
|
|
119
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
120
|
+
requirements:
|
|
121
|
+
- - ">="
|
|
122
|
+
- !ruby/object:Gem::Version
|
|
123
|
+
version: '0'
|
|
124
|
+
requirements: []
|
|
125
|
+
rubygems_version: 3.5.22
|
|
126
|
+
signing_key:
|
|
127
|
+
specification_version: 4
|
|
128
|
+
summary: Instagram/Facebook Shops adapter for portage-ucp — catalog/order over MCP
|
|
129
|
+
and UCP, redirect-link checkout
|
|
130
|
+
test_files: []
|