Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions adapters/instagram-style/adapter.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
93 changes: 93 additions & 0 deletions adapters/instagram-style/scripts/comments.star
Original file line number Diff line number Diff line change
@@ -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})
26 changes: 26 additions & 0 deletions adapters/instagram-style/scripts/oauth.star
Original file line number Diff line number Diff line change
Expand Up @@ -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})
79 changes: 79 additions & 0 deletions internal/engine/instagram_style_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
Expand Down Expand Up @@ -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)
Expand Down
Loading