creatorloop 0.1.0__py3-none-any.whl

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.
@@ -0,0 +1,133 @@
1
+ """Helper CLI: exchange tokens and find your Instagram professional account ID.
2
+
3
+ Usage:
4
+ uv run creatorloop-auth exchange --short-token <TOKEN> [--write]
5
+ uv run creatorloop-auth whoami [--write]
6
+ uv run creatorloop-auth check
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import argparse
12
+ import sys
13
+ from datetime import UTC, datetime, timedelta
14
+ from pathlib import Path
15
+
16
+ import httpx
17
+
18
+ from creatorloop import DISCLAIMER, paths
19
+ from creatorloop.config import load_settings
20
+
21
+ ENV_PATH = paths.home_dir() / ".env"
22
+
23
+
24
+ def _get(url: str, params: dict[str, str]) -> dict:
25
+ resp = httpx.get(url, params=params, timeout=30)
26
+ body = resp.json()
27
+ if "error" in body:
28
+ err = body["error"]
29
+ sys.exit(f"Graph API error: {err.get('message')} (code={err.get('code')})")
30
+ return body
31
+
32
+
33
+ def _write_env(key: str, value: str, path: Path = ENV_PATH) -> None:
34
+ path.parent.mkdir(parents=True, exist_ok=True)
35
+ lines = path.read_text(encoding="utf-8").splitlines() if path.exists() else []
36
+ for i, line in enumerate(lines):
37
+ if line.split("=", 1)[0].strip() == key:
38
+ lines[i] = f"{key}={value}"
39
+ break
40
+ else:
41
+ lines.append(f"{key}={value}")
42
+ path.write_text("\n".join(lines) + "\n", encoding="utf-8")
43
+ print(f" -> saved {key} to {path}")
44
+
45
+
46
+ def cmd_exchange(args: argparse.Namespace) -> None:
47
+ s = load_settings()
48
+ if not (s.app_id and s.app_secret):
49
+ sys.exit(f"Set META_APP_ID and META_APP_SECRET in {ENV_PATH} first.")
50
+ body = _get(
51
+ f"{s.base_url}/oauth/access_token",
52
+ {
53
+ "grant_type": "fb_exchange_token",
54
+ "client_id": s.app_id,
55
+ "client_secret": s.app_secret,
56
+ "fb_exchange_token": args.short_token,
57
+ },
58
+ )
59
+ token = body["access_token"]
60
+ expires_in = int(body.get("expires_in") or 0)
61
+ expiry = (datetime.now(UTC) + timedelta(seconds=expires_in)).date() if expires_in else "unknown (~60 days)"
62
+ print(f"Long-lived token received, expires: {expiry}")
63
+ if args.write:
64
+ _write_env("IG_ACCESS_TOKEN", token)
65
+ else:
66
+ print(token)
67
+
68
+
69
+ def cmd_whoami(args: argparse.Namespace) -> None:
70
+ s = load_settings()
71
+ if not s.access_token:
72
+ sys.exit("IG_ACCESS_TOKEN is empty.")
73
+ body = _get(
74
+ f"{s.base_url}/me/accounts",
75
+ {"fields": "name,instagram_business_account{id,username,followers_count}", "access_token": s.access_token},
76
+ )
77
+ found = []
78
+ for page in body.get("data", []):
79
+ ig = page.get("instagram_business_account")
80
+ status = f"IG @{ig['username']} id={ig['id']} followers={ig.get('followers_count')}" if ig else "no IG linked"
81
+ print(f"Facebook Page '{page.get('name')}': {status}")
82
+ if ig:
83
+ found.append(ig["id"])
84
+ if not found:
85
+ sys.exit(
86
+ "No Instagram professional account found. Check that IG is Business/Creator, linked to a Facebook Page, "
87
+ "and the token has instagram_basic + pages_show_list."
88
+ )
89
+ if args.write:
90
+ _write_env("IG_USER_ID", found[0])
91
+
92
+
93
+ def cmd_check(_: argparse.Namespace) -> None:
94
+ s = load_settings()
95
+ s.require_credentials()
96
+ body = _get(f"{s.base_url}/{s.ig_user_id}", {"fields": "username,followers_count", "access_token": s.access_token})
97
+ print(f"OK: @{body.get('username')} followers={body.get('followers_count')} api={s.api_version}")
98
+ dbg = _get(f"{s.base_url}/debug_token", {"input_token": s.access_token, "access_token": s.access_token})
99
+ exp = dbg.get("data", {}).get("expires_at") or 0
100
+ if exp:
101
+ left = (datetime.fromtimestamp(exp, UTC) - datetime.now(UTC)).days
102
+ print(f"Token expires {datetime.fromtimestamp(exp, UTC).date()} ({left} days left)")
103
+ else:
104
+ print("Token never expires")
105
+
106
+
107
+ def main() -> None:
108
+ parser = argparse.ArgumentParser(prog="creatorloop-auth", description=__doc__.split("\n")[0], epilog=DISCLAIMER)
109
+ sub = parser.add_subparsers(required=True)
110
+
111
+ p = sub.add_parser("exchange", help="Exchange short-lived user token for a long-lived (60 days) one")
112
+ p.add_argument("--short-token", required=True)
113
+ p.add_argument("--write", action="store_true", help="Save to .env instead of printing")
114
+ p.set_defaults(func=cmd_exchange)
115
+
116
+ p = sub.add_parser("whoami", help="List Facebook Pages and linked Instagram account IDs")
117
+ p.add_argument("--write", action="store_true", help="Save the first IG_USER_ID to .env")
118
+ p.set_defaults(func=cmd_whoami)
119
+
120
+ p = sub.add_parser("check", help="Verify .env credentials with a test request")
121
+ p.set_defaults(func=cmd_check)
122
+
123
+ args = parser.parse_args()
124
+ try:
125
+ args.func(args)
126
+ except RuntimeError as exc: # missing settings etc.: show the message, not a traceback
127
+ sys.exit(f"Error: {exc}")
128
+ except httpx.HTTPError as exc:
129
+ sys.exit(f"Network error: {exc}")
130
+
131
+
132
+ if __name__ == "__main__":
133
+ main()
@@ -0,0 +1,111 @@
1
+ """Comments on own posts: digest of new / unanswered comments and replies (only after user approval).
2
+
3
+ Needs the token permission instagram_manage_comments (README, section 3).
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import asyncio
9
+ from typing import Any
10
+
11
+ from creatorloop import analytics
12
+ from creatorloop.graph_client import GraphAPIError
13
+
14
+ COMMENT_FIELDS = "id,text,username,timestamp,like_count,replies{id,text,username,timestamp,like_count}"
15
+ MAX_REPLY = 2200
16
+ PERMISSION_CODES = {10, 200} # (#10) / (#200) permission errors
17
+
18
+
19
+ class CommentsError(RuntimeError):
20
+ pass
21
+
22
+
23
+ def _permission_hint(exc: GraphAPIError) -> CommentsError:
24
+ if exc.code in PERMISSION_CODES or "permission" in exc.message.lower():
25
+ return CommentsError(
26
+ "The token has no instagram_manage_comments permission. Regenerate it in Graph API Explorer with "
27
+ "instagram_manage_comments added, then run `uv run creatorloop-auth exchange --short-token <TOKEN> "
28
+ f"--write` (README, section 3). Graph API said: {exc}"
29
+ )
30
+ return CommentsError(f"Graph API error: {exc}")
31
+
32
+
33
+ def _unanswered(c: dict[str, Any], me: str) -> list[dict[str, Any]]:
34
+ """Messages in one thread (top-level comment + its replies) written by others AFTER my last word there.
35
+
36
+ A thread I already answered still yields new items when the person writes again. Instagram only allows
37
+ replies to top-level comments, so every item carries `reply_to` = the top-level id to answer in that thread.
38
+ """
39
+ replies = list((c.get("replies") or {}).get("data") or [])
40
+ if all(r.get("timestamp") for r in replies):
41
+ replies.sort(key=lambda r: r["timestamp"]) # chronological; otherwise keep the API order
42
+ msgs = [c] + replies
43
+ own = [i for i, x in enumerate(msgs) if (x.get("username") or "").lower() == me]
44
+ last_mine = own[-1] if own else -1
45
+ items = []
46
+ for i, x in enumerate(msgs):
47
+ if i <= last_mine or (x.get("username") or "").lower() == me:
48
+ continue # mine, or already answered by a later message of mine
49
+ in_thread = x is not c
50
+ items.append({
51
+ "comment_id": x["id"],
52
+ "reply_to": c["id"],
53
+ "username": x.get("username"),
54
+ "text": x.get("text"),
55
+ "timestamp": x.get("timestamp"),
56
+ "likes": x.get("like_count"),
57
+ "in_thread": in_thread,
58
+ "thread_start": {"username": c.get("username"), "text": c.get("text")} if in_thread else None,
59
+ })
60
+ return items
61
+
62
+
63
+ async def new_comments(svc, *, days: int = 14, only_new: bool = True, mark_seen: bool = True) -> dict[str, Any]:
64
+ me = (await svc.my_username()).lower()
65
+ media = [m for m in analytics.media_since(await svc.my_media(limit=50, refresh=True), days)
66
+ if (m.get("comments_count") or 0) > 0]
67
+ sem = asyncio.Semaphore(4)
68
+
69
+ async def fetch(m: dict[str, Any]) -> tuple[dict[str, Any], list[dict[str, Any]]]:
70
+ async with sem:
71
+ try:
72
+ items = await svc.client.paginate(f"/{m['id']}/comments", {"fields": COMMENT_FIELDS}, max_items=100)
73
+ except GraphAPIError as exc:
74
+ raise _permission_hint(exc) from exc
75
+ return m, items
76
+
77
+ results = await asyncio.gather(*(fetch(m) for m in media))
78
+ out = []
79
+ for m, items in results:
80
+ post = {"media_id": m["id"], "permalink": m.get("permalink"), "hook": analytics.caption_hook(m.get("caption"), 80)}
81
+ for c in items:
82
+ for item in _unanswered(c, me):
83
+ seen = svc.storage.comment_seen(item["comment_id"])
84
+ if only_new and seen:
85
+ continue
86
+ out.append({**item, "post": post, "already_seen": bool(seen)})
87
+ if mark_seen:
88
+ for c in out:
89
+ svc.storage.comment_mark(c["comment_id"], c["post"]["media_id"])
90
+ out.sort(key=lambda c: c["timestamp"] or "", reverse=True)
91
+ return {
92
+ "comments": out,
93
+ "posts_checked": len(media),
94
+ "next": "Draft a short reply for each (same language as the comment, the user's tone). Send with "
95
+ "reply_comment(comment_id=<reply_to>) ONLY after the user explicitly approves that exact text; "
96
+ "in_thread items are answers inside a thread, thread_start shows what they reply to.",
97
+ }
98
+
99
+
100
+ async def reply(svc, comment_id: str, message: str) -> dict[str, Any]:
101
+ text = message.strip()
102
+ if not text:
103
+ raise CommentsError("Empty reply")
104
+ if len(text) > MAX_REPLY:
105
+ raise CommentsError(f"Reply is {len(text)} chars (max {MAX_REPLY})")
106
+ try:
107
+ body = await svc.client.post(f"/{comment_id}/replies", {"message": text})
108
+ except GraphAPIError as exc:
109
+ raise _permission_hint(exc) from exc
110
+ svc.storage.comment_mark(comment_id, None, reply_id=body.get("id"))
111
+ return {"replied": True, "reply_id": body.get("id"), "comment_id": comment_id, "text": text}
creatorloop/config.py ADDED
@@ -0,0 +1,110 @@
1
+ """Settings loaded from environment variables / .env files (see creatorloop.paths for locations)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from dataclasses import dataclass
7
+ from datetime import datetime, tzinfo
8
+ from pathlib import Path
9
+ from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
10
+
11
+ from dotenv import load_dotenv
12
+
13
+ from creatorloop import paths
14
+
15
+ DEFAULT_API_VERSION = "v26.0"
16
+
17
+
18
+ def _load_env() -> None:
19
+ # Real environment variables win (override=False), then the user's home .env, then ./.env (development).
20
+ # Claude Desktop starts the process with an arbitrary cwd, so the home .env is the one that matters.
21
+ load_dotenv(paths.home_dir() / ".env", override=False)
22
+ load_dotenv(Path.cwd() / ".env", override=False)
23
+
24
+
25
+ def _zone(name: str) -> ZoneInfo | None:
26
+ try:
27
+ return ZoneInfo(name.strip()) if name.strip() else None
28
+ except (ZoneInfoNotFoundError, ValueError):
29
+ return None
30
+
31
+
32
+ def system_timezone(localtime: Path = Path("/etc/localtime"), timezone_file: Path = Path("/etc/timezone")) -> tzinfo:
33
+ """The computer's timezone: TZ, the /etc/localtime link (macOS, Linux), /etc/timezone (Debian),
34
+ else the current fixed UTC offset (Windows; set IG_TIMEZONE there to get daylight-saving changes right)."""
35
+ if tz := _zone(os.getenv("TZ", "").lstrip(":")):
36
+ return tz
37
+ try:
38
+ target = str(localtime.resolve(strict=True))
39
+ if "zoneinfo/" in target and (tz := _zone(target.split("zoneinfo/", 1)[1])):
40
+ return tz
41
+ except OSError:
42
+ pass
43
+ try:
44
+ if tz := _zone(timezone_file.read_text(encoding="utf-8")):
45
+ return tz
46
+ except OSError:
47
+ pass
48
+ return datetime.now().astimezone().tzinfo or ZoneInfo("UTC")
49
+
50
+
51
+ def _int(name: str, default: int) -> int:
52
+ raw = os.getenv(name, "").strip()
53
+ return int(raw) if raw else default
54
+
55
+
56
+ @dataclass(frozen=True, slots=True)
57
+ class Settings:
58
+ access_token: str
59
+ ig_user_id: str
60
+ api_version: str
61
+ app_id: str | None
62
+ app_secret: str | None
63
+ db_path: Path
64
+ timezone: tzinfo
65
+ ttl_profile_s: int
66
+ ttl_media_s: int
67
+ ttl_hashtag_s: int
68
+
69
+ @property
70
+ def base_url(self) -> str:
71
+ return f"https://graph.facebook.com/{self.api_version}"
72
+
73
+ def require_credentials(self) -> None:
74
+ missing = [
75
+ name
76
+ for name, value in (("IG_ACCESS_TOKEN", self.access_token), ("IG_USER_ID", self.ig_user_id))
77
+ if not value
78
+ ]
79
+ if missing:
80
+ raise RuntimeError(
81
+ f"Missing required settings: {', '.join(missing)}. "
82
+ f"Fill them in {paths.home_dir() / '.env'} (run creatorloop-setup to create it)."
83
+ )
84
+
85
+
86
+ def _configured_timezone() -> tzinfo:
87
+ raw = os.getenv("IG_TIMEZONE", "").strip()
88
+ if not raw:
89
+ return system_timezone()
90
+ if tz := _zone(raw):
91
+ return tz
92
+ raise RuntimeError(f"IG_TIMEZONE={raw!r} is not a known timezone; use an IANA name like 'Europe/Berlin'.")
93
+
94
+
95
+ def load_settings() -> Settings:
96
+ _load_env()
97
+ db_raw = os.getenv("IG_DB_PATH", "").strip()
98
+ db_path = Path(db_raw).expanduser() if db_raw else paths.home_dir() / "creatorloop.db"
99
+ return Settings(
100
+ access_token=os.getenv("IG_ACCESS_TOKEN", "").strip(),
101
+ ig_user_id=os.getenv("IG_USER_ID", "").strip(),
102
+ api_version=os.getenv("GRAPH_API_VERSION", DEFAULT_API_VERSION).strip() or DEFAULT_API_VERSION,
103
+ app_id=os.getenv("META_APP_ID", "").strip() or None,
104
+ app_secret=os.getenv("META_APP_SECRET", "").strip() or None,
105
+ db_path=db_path,
106
+ timezone=_configured_timezone(),
107
+ ttl_profile_s=_int("CACHE_TTL_PROFILE_MIN", 60) * 60,
108
+ ttl_media_s=_int("CACHE_TTL_MEDIA_MIN", 180) * 60,
109
+ ttl_hashtag_s=_int("CACHE_TTL_HASHTAG_MIN", 360) * 60,
110
+ )
@@ -0,0 +1,33 @@
1
+ # creatorloop settings. `creatorloop-setup` creates this file in your creatorloop home folder;
2
+ # fill in the two required values below and restart Claude Desktop.
3
+ # Commands below are for a PyPI install; from a source checkout use `uv run creatorloop-auth ...`.
4
+
5
+ # Long-lived user access token (60 days) or a System User token (never expires).
6
+ # Get it with: uvx --from creatorloop creatorloop-auth exchange --short-token <TOKEN> --write
7
+ IG_ACCESS_TOKEN=
8
+
9
+ # Instagram professional account ID (NOT the username). 17841400000000000-like number.
10
+ # Get it with: uvx --from creatorloop creatorloop-auth whoami --write
11
+ IG_USER_ID=
12
+
13
+ # Graph API version. Check https://developers.facebook.com/docs/graph-api/changelog
14
+ GRAPH_API_VERSION=v26.0
15
+
16
+ # Optional: needed only for the token exchange and token expiry check.
17
+ META_APP_ID=
18
+ META_APP_SECRET=
19
+
20
+ # Optional: SQLite database location (default: creatorloop.db in the creatorloop home folder).
21
+ IG_DB_PATH=
22
+
23
+ # Optional: IANA timezone for "best time to post" and the queue, e.g. Europe/Berlin.
24
+ # Default: your computer's timezone (on Windows set it explicitly to get daylight-saving changes right).
25
+ IG_TIMEZONE=
26
+
27
+ # Optional: publishing slots for queue_post(when="next"), local time. Example, not a recommendation.
28
+ QUEUE_SLOTS=Tue,Thu,Sun 19:00
29
+
30
+ # Cache TTL in minutes.
31
+ CACHE_TTL_PROFILE_MIN=60
32
+ CACHE_TTL_MEDIA_MIN=180
33
+ CACHE_TTL_HASHTAG_MIN=360
@@ -0,0 +1,224 @@
1
+ """Thin async client for the Facebook Graph API (Instagram API with Facebook Login)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import json
7
+ import logging
8
+ from dataclasses import dataclass, field
9
+ from pathlib import Path
10
+ from typing import Any
11
+
12
+ import httpx
13
+
14
+ log = logging.getLogger(__name__)
15
+
16
+ # https://developers.facebook.com/docs/graph-api/overview/rate-limiting
17
+ RATE_LIMIT_CODES = {4, 17, 32, 613, 80001, 80002, 80006}
18
+ TRANSIENT_CODES = {1, 2}
19
+ TOKEN_ERROR_CODE = 190
20
+ RUPLOAD_HOST = "https://rupload.facebook.com" # resumable video uploads (Reels)
21
+
22
+
23
+ class GraphAPIError(Exception):
24
+ """Error returned by the Graph API."""
25
+
26
+ def __init__(
27
+ self,
28
+ message: str,
29
+ *,
30
+ code: int | None = None,
31
+ subcode: int | None = None,
32
+ status: int | None = None,
33
+ fbtrace_id: str | None = None,
34
+ transient: bool = False,
35
+ ) -> None:
36
+ super().__init__(message)
37
+ self.message = message
38
+ self.code = code
39
+ self.subcode = subcode
40
+ self.status = status
41
+ self.fbtrace_id = fbtrace_id
42
+ self.transient = transient
43
+
44
+ def __str__(self) -> str:
45
+ parts = [self.message]
46
+ if self.code is not None:
47
+ parts.append(f"code={self.code}")
48
+ if self.subcode is not None:
49
+ parts.append(f"subcode={self.subcode}")
50
+ if self.fbtrace_id:
51
+ parts.append(f"fbtrace_id={self.fbtrace_id}")
52
+ return " | ".join(parts)
53
+
54
+
55
+ class RateLimitError(GraphAPIError):
56
+ """Rate limit reached. Retrying immediately makes it worse, so we do not retry."""
57
+
58
+
59
+ class TokenError(GraphAPIError):
60
+ """Access token is invalid or expired."""
61
+
62
+
63
+ @dataclass(slots=True)
64
+ class UsageInfo:
65
+ """Last seen rate-limit usage headers."""
66
+
67
+ app_usage: dict[str, Any] = field(default_factory=dict)
68
+ business_usage: dict[str, Any] = field(default_factory=dict)
69
+ instagram_usage: dict[str, Any] = field(default_factory=dict)
70
+
71
+
72
+ def _parse_json_header(value: str | None) -> dict[str, Any]:
73
+ if not value:
74
+ return {}
75
+ try:
76
+ parsed = json.loads(value)
77
+ except ValueError:
78
+ return {}
79
+ return parsed if isinstance(parsed, dict) else {}
80
+
81
+
82
+ class GraphClient:
83
+ def __init__(
84
+ self,
85
+ base_url: str,
86
+ access_token: str,
87
+ *,
88
+ timeout: float = 30.0,
89
+ max_retries: int = 3,
90
+ transport: httpx.AsyncBaseTransport | None = None,
91
+ ) -> None:
92
+ self._access_token = access_token
93
+ self._max_retries = max_retries
94
+ self._client = httpx.AsyncClient(base_url=base_url, timeout=timeout, transport=transport)
95
+ self.usage = UsageInfo()
96
+
97
+ async def aclose(self) -> None:
98
+ await self._client.aclose()
99
+
100
+ async def get(self, path: str, params: dict[str, Any] | None = None) -> dict[str, Any]:
101
+ return await self._request("GET", path, params, retries=self._max_retries)
102
+
103
+ async def post(self, path: str, params: dict[str, Any] | None = None, *, retries: int = 0) -> dict[str, Any]:
104
+ """POST with form params. No retries by default: repeating a publish call could post twice."""
105
+ return await self._request("POST", path, params, retries=retries)
106
+
107
+ async def _request(self, method: str, path: str, params: dict[str, Any] | None, *, retries: int) -> dict[str, Any]:
108
+ query = {k: v for k, v in (params or {}).items() if v is not None}
109
+ query.setdefault("access_token", self._access_token)
110
+
111
+ attempt = 0
112
+ while True:
113
+ attempt += 1
114
+ try:
115
+ if method == "GET":
116
+ response = await self._client.get(path, params=query)
117
+ else:
118
+ response = await self._client.post(path, data=query)
119
+ except httpx.TransportError as exc:
120
+ if attempt > retries:
121
+ raise GraphAPIError(f"Network error: {exc}", transient=True) from exc
122
+ await self._backoff(attempt, reason=str(exc))
123
+ continue
124
+
125
+ self._record_usage(response)
126
+ try:
127
+ return self._handle(response)
128
+ except GraphAPIError as exc:
129
+ retryable = exc.transient or (exc.status is not None and exc.status >= 500)
130
+ if isinstance(exc, (RateLimitError, TokenError)) or not retryable or attempt > retries:
131
+ raise
132
+ await self._backoff(attempt, reason=str(exc))
133
+
134
+ @property
135
+ def api_version(self) -> str:
136
+ return str(self._client.base_url).rstrip("/").rsplit("/", 1)[-1]
137
+
138
+ async def upload_video(self, container_id: str, path: Path, *, timeout: float = 600.0) -> dict[str, Any]:
139
+ """Resumable upload of a local video into a REELS container created with upload_type=resumable.
140
+
141
+ POST https://rupload.facebook.com/ig-api-upload/<version>/<container_id>, headers Authorization/offset/file_size.
142
+ Sent in one request from offset 0. Not retried automatically (a partial upload is simply redone by prepare).
143
+ """
144
+ url = f"{RUPLOAD_HOST}/ig-api-upload/{self.api_version}/{container_id}"
145
+ data = await asyncio.to_thread(path.read_bytes)
146
+ headers = {"Authorization": f"OAuth {self._access_token}", "offset": "0", "file_size": str(len(data))}
147
+ try:
148
+ response = await self._client.post(url, content=data, headers=headers, timeout=timeout)
149
+ except httpx.TransportError as exc:
150
+ raise GraphAPIError(f"Video upload failed: {exc}", transient=True) from exc
151
+ try:
152
+ body = response.json()
153
+ except ValueError:
154
+ body = {}
155
+ if not response.is_success or not body.get("success"):
156
+ debug = body.get("debug_info") or body.get("error") or response.text[:300]
157
+ raise GraphAPIError(f"Video upload rejected (HTTP {response.status_code}): {debug}", status=response.status_code)
158
+ return body
159
+
160
+ async def paginate(
161
+ self, path: str, params: dict[str, Any] | None = None, *, max_items: int = 100
162
+ ) -> list[dict[str, Any]]:
163
+ """Follow cursor pagination (`paging.cursors.after`) until max_items."""
164
+ items: list[dict[str, Any]] = []
165
+ page_params = dict(params or {})
166
+ while len(items) < max_items:
167
+ page_params["limit"] = min(page_params.get("limit") or 50, max_items - len(items), 100)
168
+ body = await self.get(path, page_params)
169
+ items.extend(body.get("data", []))
170
+ after = body.get("paging", {}).get("cursors", {}).get("after")
171
+ if not after or not body.get("paging", {}).get("next"):
172
+ break
173
+ page_params["after"] = after
174
+ return items[:max_items]
175
+
176
+ async def _backoff(self, attempt: int, *, reason: str) -> None:
177
+ delay = min(2 ** (attempt - 1), 8)
178
+ log.warning("Graph API retry %s in %ss: %s", attempt, delay, reason)
179
+ await asyncio.sleep(delay)
180
+
181
+ def _record_usage(self, response: httpx.Response) -> None:
182
+ headers = response.headers
183
+ if app := _parse_json_header(headers.get("x-app-usage")):
184
+ self.usage.app_usage = app
185
+ if biz := _parse_json_header(headers.get("x-business-use-case-usage")):
186
+ self.usage.business_usage = biz
187
+ if ig := _parse_json_header(headers.get("x-ig-business-use-case-usage")):
188
+ self.usage.instagram_usage = ig
189
+
190
+ @staticmethod
191
+ def _handle(response: httpx.Response) -> dict[str, Any]:
192
+ try:
193
+ body = response.json()
194
+ except ValueError:
195
+ if response.is_success:
196
+ raise GraphAPIError("Non-JSON response from Graph API", status=response.status_code) from None
197
+ raise GraphAPIError(
198
+ f"HTTP {response.status_code}: {response.text[:300]}", status=response.status_code
199
+ ) from None
200
+
201
+ if response.is_success and "error" not in body:
202
+ return body
203
+
204
+ err = body.get("error", {}) if isinstance(body, dict) else {}
205
+ message = err.get("error_user_msg") or err.get("message") or f"HTTP {response.status_code}"
206
+ code = err.get("code")
207
+ kwargs = dict(
208
+ code=code,
209
+ subcode=err.get("error_subcode"),
210
+ status=response.status_code,
211
+ fbtrace_id=err.get("fbtrace_id"),
212
+ transient=bool(err.get("is_transient")) or code in TRANSIENT_CODES,
213
+ )
214
+ if code in RATE_LIMIT_CODES:
215
+ raise RateLimitError(
216
+ f"Rate limit reached: {message}. Wait before the next request (see get_api_status).",
217
+ **kwargs,
218
+ )
219
+ if code == TOKEN_ERROR_CODE:
220
+ raise TokenError(
221
+ f"Access token problem: {message}. Refresh IG_ACCESS_TOKEN (README, section 'Token').",
222
+ **kwargs,
223
+ )
224
+ raise GraphAPIError(message, **kwargs)