From 6c756684b5274f0a04c63be5a10b0e83f9ccfca3 Mon Sep 17 00:00:00 2001 From: Alessandro De Blasis Date: Mon, 17 Aug 2026 04:02:51 +0300 Subject: [PATCH] feat(instagram-style): media comments + long-lived token refresh MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GET /v21.0/{media_id}/comments — the Graph comment-reader surface: authorizes via the access_token query param (how real SDK clients call it) or bearer, synthesizes two deterministic per-media comments (id/text/username/timestamp/like_count), honors the Graph since filter, 404s unknown media. GET /v21.0/refresh_access_token — long-lived token refresh (grant_type=ig_refresh_token): validates the presented token, mints a fresh 60-day token for the same user, old token stays valid until its own expiry. Literal route precedes /v21.0/{container_id}. Prepares the adapter for real client integrations whose comment-ingest and token-refresh paths need somewhere faithful to run. --- adapters/instagram-style/adapter.yaml | 10 ++ .../instagram-style/scripts/comments.star | 93 +++++++++++++++++++ adapters/instagram-style/scripts/oauth.star | 26 ++++++ internal/engine/instagram_style_test.go | 79 ++++++++++++++++ 4 files changed, 208 insertions(+) create mode 100644 adapters/instagram-style/scripts/comments.star diff --git a/adapters/instagram-style/adapter.yaml b/adapters/instagram-style/adapter.yaml index ec037a83..214da7db 100644 --- a/adapters/instagram-style/adapter.yaml +++ b/adapters/instagram-style/adapter.yaml @@ -30,11 +30,21 @@ endpoints: method: GET handler: scripts/profile.star#on_profile + # --- Long-lived token refresh (LITERAL: must precede /v21.0/{container_id}) --- + - route: /v21.0/refresh_access_token + method: GET + handler: scripts/oauth.star#on_refresh_token + # --- Container processing status (poll until FINISHED, then publish) --- - route: /v21.0/{container_id} method: GET handler: scripts/publish.star#on_container_status + # --- Comments (per-media) --- + - route: /v21.0/{media_id}/comments + method: GET + handler: scripts/comments.star#on_comments + # --- Insights (per-media metrics) --- - route: /v21.0/{media_id}/insights method: GET diff --git a/adapters/instagram-style/scripts/comments.star b/adapters/instagram-style/scripts/comments.star new file mode 100644 index 00000000..0da206ca --- /dev/null +++ b/adapters/instagram-style/scripts/comments.star @@ -0,0 +1,93 @@ +# Comment readers (Graph API surface): GET /v21.0/{media_id}/comments +# (docs: instagram-graph-api reference "Get comments on a media object"). +# +# Real clients authorize with the access_token QUERY param on this endpoint +# (the SDK helper appends ?access_token=...), not only the bearer header — +# both are honored. Comments are synthesized deterministically from the media +# id (stable across reads, distinct per media), and the Graph `since` filter +# (unix seconds) hides comments at-or-before the cutoff like the real API. + +# Shared helpers (_bearer, _get_query, _to_int) are preloaded from lib.star. + +# _valid_token reports whether tok was minted by the OAuth flow and is not +# expired (same policy as lib._bearer_present, for a token string argument). +def _valid_token(tok): + if tok == "": + return False + tc = store_collection("tokens") + doc = tc.get(tok) + if doc == None: + return False + exp = doc.get("expires_at", 0) + if exp != 0 and clock.now_unix() > exp: + return False + return True + +# _h64 folds a string into a stable small int (djb-style mix) — +# deterministic per-media comment seeds without wall-clock dependence. +def _h64(s): + h = 5381 + for i in range(len(s)): + h = ((h * 33) + ord(s[i])) % 1000003 + return h + +# _rfc_to_unix parses "YYYY-MM-DDTHH:MM:SS..." (the media timestamp format, +# offset ignored — stamps are UTC) to unix seconds. Unparseable input falls +# back to now so comment reads never crash on an odd stamp. +def _rfc_to_unix(s): + if s == None or len(s) < 19: + return clock.now_unix() + y = _to_int(s[0:4]) + mo = _to_int(s[5:7]) + d = _to_int(s[8:10]) + hh = _to_int(s[11:13]) + mi = _to_int(s[14:16]) + ss = _to_int(s[17:19]) + # days from civil (Hinnant), epoch 1970-01-01 + yy = y + mp = mo - 3 + if mo <= 2: + yy = y - 1 + mp = mo + 9 + era = yy // 400 + yoe = yy - era * 400 + doy = (153 * mp + 2) // 5 + d - 1 + doe = yoe * 365 + yoe // 4 - yoe // 100 + doy + days = era * 146097 + doe - 719468 + return days * 86400 + hh * 3600 + mi * 60 + ss + +def on_comments(req): + q = req.get("query") + token = _get_query(req, "access_token", "") + if token == "": + token = _bearer(req) + if not _valid_token(token): + return respond(401, {"error": {"message": "Missing or invalid access token", "type": "OAuthException", "code": 190, "fbtrace_id": "synthetic_fbtrace_id_190"}}) + + media_id = req["params"].get("media_id", "") + mc = store_collection("media") + media = mc.get(media_id) + if media == None: + return respond(404, {"error": {"message": "resource not found", "type": "OAuthException", "code": 100, "fbtrace_id": "synthetic_fbtrace_id_100"}}) + + base_ts = _rfc_to_unix(media.get("timestamp", None)) + since = _to_int(_get_query(req, "since", "0")) + + h = _h64(media_id) + users = ["artlover", "printsfan", "canvas.curious", "studio.visitor"] + out = [] + for i in range(2): + # Two comments, staggered minutes after the post; `since` filters. + ts = base_ts + (i + 1) * (60 + (h % 7)) + if since > 0 and ts <= since: + continue + k = (h + i * 97) % 1000 + out.append({ + "id": "cmt_" + media_id + "_" + str(i + 1), + "text": "This is beautiful — " + str(k) + " prints please!", + "username": users[(h + i) % 4], + "timestamp": clock.unix_to_rfc3339(ts), + "like_count": k % 12, + }) + + return respond(200, {"data": out}) diff --git a/adapters/instagram-style/scripts/oauth.star b/adapters/instagram-style/scripts/oauth.star index 1035bd42..fe848b22 100644 --- a/adapters/instagram-style/scripts/oauth.star +++ b/adapters/instagram-style/scripts/oauth.star @@ -95,3 +95,29 @@ def _mint_token(user): "expires_at": clock.now_unix() + 60 * 24 * 3600, }) return token + +# on_refresh_token handles GET /v21.0/refresh_access_token (long-lived token +# refresh). Real endpoint authorizes via the access_token QUERY param with +# grant_type=ig_refresh_token, and returns a FRESH 60-day token; the old one +# keeps working until its own expiry (no rotation invalidation). +def on_refresh_token(req): + token = _get_query(req, "access_token", "") + if token == "": + token = _bearer(req) + if token == "": + return respond(400, {"error": {"message": "An access token is required", "type": "OAuthException", "code": 190}}) + + grant = _get_query(req, "grant_type", "") + if grant != "ig_refresh_token": + return respond(400, {"error": {"message": "grant_type must be ig_refresh_token", "type": "OAuthException", "code": 1}}) + + tc = store_collection("tokens") + doc = tc.get(token) + if doc == None: + return respond(400, {"error": {"message": "Invalid OAuth access token", "type": "OAuthException", "code": 190}}) + exp = doc.get("expires_at", 0) + if exp != 0 and clock.now_unix() > exp: + return respond(400, {"error": {"message": "The access token has expired", "type": "OAuthException", "code": 190}}) + + fresh = _mint_token({"ig_user_id": doc.get("ig_user_id", ""), "username": doc.get("username", "")}) + return respond(200, {"access_token": fresh, "token_type": "bearer", "expires_in": 60 * 24 * 3600}) diff --git a/internal/engine/instagram_style_test.go b/internal/engine/instagram_style_test.go index e17cb875..2c5ebb14 100644 --- a/internal/engine/instagram_style_test.go +++ b/internal/engine/instagram_style_test.go @@ -4,6 +4,7 @@ import ( "bytes" "context" "encoding/json" + "fmt" "io" "net/http" "net/url" @@ -274,6 +275,84 @@ func TestInstagramStyleAdapter(t *testing.T) { t.Fatalf("bad container -> status %d, want 404; body %s", status, body) } + // ===== Comments → query-param access_token, data shape, since filter ===== + + body, status = get2(t, base+"/v21.0/"+mediaID+"/comments?access_token="+accessToken) + if status != 200 { + t.Fatalf("comments (query-param token) -> status %d, want 200; body %s", status, body) + } + var commentsResp map[string]any + if err := json.Unmarshal([]byte(body), &commentsResp); err != nil { + t.Fatalf("unmarshal comments: %v (body %s)", err, body) + } + commentsArr, ok := commentsResp["data"].([]any) + if !ok || len(commentsArr) != 2 { + t.Fatalf("comments data = %v, want 2 deterministic comments", commentsResp["data"]) + } + firstComment, ok := commentsArr[0].(map[string]any) + if !ok || firstComment["username"] == "" || firstComment["text"] == "" { + t.Fatalf("comment[0] = %v, want id/text/username/timestamp", commentsArr[0]) + } + if _, ok := firstComment["timestamp"].(string); !ok { + t.Fatalf("comment timestamp = %v, want string", firstComment["timestamp"]) + } + // Stable across reads (deterministic per media). + body2, _ := get2(t, base+"/v21.0/"+mediaID+"/comments?access_token="+accessToken) + if body2 != body { + t.Fatalf("comments not deterministic across reads:\n%s\n%s", body, body2) + } + // No token → 401; unknown media → 404; far-future since hides everything. + _, status = get2(t, base+"/v21.0/"+mediaID+"/comments") + if status != 401 { + t.Fatalf("comments without token -> status %d, want 401", status) + } + _, status = get2(t, base+"/v21.0/m_nope/comments?access_token="+accessToken) + if status != 404 { + t.Fatalf("comments unknown media -> status %d, want 404", status) + } + body, status = get2(t, base+"/v21.0/"+mediaID+"/comments?access_token="+accessToken+"&since="+fmt.Sprintf("%d", time.Now().Add(1*time.Hour).Unix())) + if status != 200 { + t.Fatalf("comments since -> status %d, want 200; body %s", status, body) + } + if err := json.Unmarshal([]byte(body), &commentsResp); err != nil { + t.Fatal(err) + } + if n := len(commentsResp["data"].([]any)); n != 0 { + t.Fatalf("comments since far-future = %d, want 0", n) + } + + // ===== Long-lived token refresh: fresh 60-day token, old one still valid ===== + + body, status = get2(t, base+"/v21.0/refresh_access_token?grant_type=ig_refresh_token&access_token="+accessToken) + if status != 200 { + t.Fatalf("refresh_access_token -> status %d, want 200; body %s", status, body) + } + var refreshResp map[string]any + if err := json.Unmarshal([]byte(body), &refreshResp); err != nil { + t.Fatal(err) + } + freshToken, ok := refreshResp["access_token"].(string) + if !ok || freshToken == "" || freshToken == accessToken { + t.Fatalf("refreshed token = %v, want a fresh token", refreshResp["access_token"]) + } + if refreshResp["expires_in"] != float64(60*24*3600) { + t.Fatalf("refresh expires_in = %v, want 60 days", refreshResp["expires_in"]) + } + // The fresh token authorizes like the original. + _, status = getAuth(t, base+"/v21.0/me", freshToken) + if status != 200 { + t.Fatalf("GET me with refreshed token -> status %d, want 200", status) + } + // Wrong grant / unknown token -> 400. + _, status = get2(t, base+"/v21.0/refresh_access_token?grant_type=nope&access_token="+accessToken) + if status != 400 { + t.Fatalf("refresh wrong grant -> status %d, want 400", status) + } + _, status = get2(t, base+"/v21.0/refresh_access_token?grant_type=ig_refresh_token&access_token=mock_token_nope") + if status != 400 { + t.Fatalf("refresh unknown token -> status %d, want 400", status) + } + // ===== Insights → all 4 metrics present, finite ===== body, status = getAuth(t, base+"/v21.0/"+mediaID+"/insights?metric=impressions,reach,likes,comments", accessToken)