@volter/twin-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.
Files changed (39) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +56 -0
  3. package/dist/client/instagram-mirror.bundle.js +321 -0
  4. package/dist/client/instagram-mirror.d.ts +58 -0
  5. package/dist/client/instagram-mirror.js +257 -0
  6. package/dist/src/cli.d.ts +2 -0
  7. package/dist/src/cli.js +30 -0
  8. package/dist/src/index.d.ts +10 -0
  9. package/dist/src/index.js +71 -0
  10. package/dist/src/instagram-budget.d.ts +44 -0
  11. package/dist/src/instagram-budget.js +112 -0
  12. package/dist/src/instagram-capabilities.d.ts +4 -0
  13. package/dist/src/instagram-capabilities.js +1249 -0
  14. package/dist/src/instagram-conformance.d.ts +8 -0
  15. package/dist/src/instagram-conformance.js +44 -0
  16. package/dist/src/instagram-connector.d.ts +79 -0
  17. package/dist/src/instagram-connector.js +437 -0
  18. package/dist/src/instagram-errors.d.ts +33 -0
  19. package/dist/src/instagram-errors.js +73 -0
  20. package/dist/src/instagram-media.d.ts +134 -0
  21. package/dist/src/instagram-media.js +388 -0
  22. package/dist/src/instagram-mirror-ui.d.ts +57 -0
  23. package/dist/src/instagram-mirror-ui.js +158 -0
  24. package/dist/src/instagram-server.d.ts +14 -0
  25. package/dist/src/instagram-server.js +146 -0
  26. package/dist/src/instagram-twin.d.ts +52 -0
  27. package/dist/src/instagram-twin.js +884 -0
  28. package/package.json +57 -0
  29. package/src/cli.ts +28 -0
  30. package/src/index.ts +117 -0
  31. package/src/instagram-budget.ts +130 -0
  32. package/src/instagram-capabilities.ts +1191 -0
  33. package/src/instagram-conformance.ts +58 -0
  34. package/src/instagram-connector.ts +400 -0
  35. package/src/instagram-errors.ts +89 -0
  36. package/src/instagram-media.ts +403 -0
  37. package/src/instagram-mirror-ui.ts +173 -0
  38. package/src/instagram-server.ts +146 -0
  39. package/src/instagram-twin.ts +859 -0
@@ -0,0 +1,8 @@
1
+ export type InstagramConformanceReport = {
2
+ ok: boolean;
3
+ checksRun: number;
4
+ failures: string[];
5
+ };
6
+ export declare function checkInstagramConformance(options?: {
7
+ root?: string;
8
+ }): Promise<InstagramConformanceReport>;
@@ -0,0 +1,44 @@
1
+ // Conformance for the Instagram surface — dev-only, imported LAZILY by cli.ts so it never enters the
2
+ // pack's runtime entrypoint graph.
3
+ //
4
+ // One real request per claimed behaviour, asserting the OUTCOME a live handler produces against the
5
+ // docs' own samples: a Reels container answers {id, uri} with the uri on the rupload host, its
6
+ // status_code reads IN_PROGRESS before the bytes, publishing it early is 9007/2207027, the IG User node
7
+ // answers the fields asked for, the publishing limit answers {data:[{quota_usage, config}]}, an
8
+ // unknown node is the Graph API's "Unsupported get request", and a missing token is refused.
9
+ import { handleInstagramTwinRequest } from "./instagram-twin.js";
10
+ export async function checkInstagramConformance(options = {}) {
11
+ const failures = [];
12
+ let checks = 0;
13
+ const check = (name, ok) => { checks += 1; if (!ok)
14
+ failures.push(name); };
15
+ const root = options.root;
16
+ const auth = { authorization: 'Bearer conformance-token-1', 'content-type': 'application/json' };
17
+ const h = (method, path, body, headers = auth) => handleInstagramTwinRequest({ method, path, headers, root, occurredAt: '2026-09-27T12:00:00.000Z', ...(body === undefined ? {} : { body: JSON.stringify(body) }) });
18
+ const ig = '17841405822304914';
19
+ await h('POST', '/_twin/users', { id: ig, username: 'metricsaurus', name: 'Metricsaurus', biography: 'Dino data crunching app', website: 'http://www.metricsaurus.com/', followers_count: 12, follows_count: 3 }, {});
20
+ await h('POST', '/_twin/tokens', { token: 'conformance-token-1', user: ig, permissions: ['instagram_basic', 'instagram_content_publish', 'pages_read_engagement'] }, {});
21
+ const user = await h('GET', `/v26.0/${ig}?fields=biography,id,username,website`);
22
+ check('the IG User node answers the reference sample\'s fields', user.status === 200
23
+ && JSON.stringify(user.body) === JSON.stringify({ biography: 'Dino data crunching app', id: ig, username: 'metricsaurus', website: 'http://www.metricsaurus.com/' }));
24
+ const created = await h('POST', `/v26.0/${ig}/media`, { media_type: 'REELS', upload_type: 'resumable', caption: 'hello #reels' });
25
+ const c = created.body;
26
+ check('a resumable Reels container answers {id, uri} on the rupload host', created.status === 200 && /^\d+$/.test(c?.id) && c?.uri === `https://rupload.facebook.com/ig-api-upload/v26.0/${c?.id}`);
27
+ const status = await h('GET', `/v26.0/${c?.id}?fields=status_code`);
28
+ check('a container with no bytes reads IN_PROGRESS', status.status === 200 && JSON.stringify(status.body) === JSON.stringify({ status_code: 'IN_PROGRESS', id: c?.id }));
29
+ const early = await h('POST', `/v26.0/${ig}/media_publish`, { creation_id: c?.id });
30
+ const e = early.body?.error;
31
+ check('publishing a container not yet FINISHED is 9007 / 2207027', early.status === 400 && e?.code === 9007 && e?.error_subcode === 2207027 && e?.type === 'OAuthException' && typeof e?.fbtrace_id === 'string');
32
+ const limit = await h('GET', `/v26.0/${ig}/content_publishing_limit?fields=quota_usage,config`);
33
+ check('content_publishing_limit answers {data:[{quota_usage, config}]}', limit.status === 200
34
+ && JSON.stringify(limit.body) === JSON.stringify({ data: [{ quota_usage: 0, config: { quota_total: 50, quota_duration: 86400 } }] }));
35
+ const list = await h('GET', `/v26.0/${ig}/media`);
36
+ check('the media edge answers {data: []} for an account with no media', list.status === 200 && JSON.stringify(list.body) === JSON.stringify({ data: [] }));
37
+ const unknown = await h('GET', '/v26.0/17841499999999999');
38
+ check('an unknown node is the Graph API\'s Unsupported get request (100 / 33)', unknown.status === 400 && unknown.body?.error?.code === 100 && unknown.body?.error?.error_subcode === 33);
39
+ const anon = await h('GET', `/v26.0/${ig}`, undefined, {});
40
+ check('no token is refused', anon.status === 400 && anon.body?.error?.type === 'OAuthException');
41
+ const image = await h('POST', `/v26.0/${ig}/media`, { image_url: 'https://www.example.com/images/bronz-fonz.jpg' });
42
+ check('an unmodelled image container is refused by name, never a success', image.status === 422 && /\[twin gap\]/.test(String(image.body?.error?.message)));
43
+ return { ok: failures.length === 0, checksRun: checks, failures };
44
+ }
@@ -0,0 +1,79 @@
1
+ import type { PerformContext, PushOutcome, RemoteExecute, TwinAction } from '@volter/world-core';
2
+ import { InstagramBudget } from './instagram-budget.js';
3
+ /** The Graph API version every call this adapter makes pins — the newest the twin models. */
4
+ export declare const INSTAGRAM_VERSION = "v26.0";
5
+ /** Meta's upload host. */
6
+ export declare const RUPLOAD_HOST = "rupload.facebook.com";
7
+ /**
8
+ * The kernel executor, charged to this pack's InstagramBudget: the check RESERVES before the call and
9
+ * throws (InstagramBudgetError) instead of calling when the ceiling, the burst bound or a cooldown says
10
+ * stop; the answer settles the reservation and arms a cooldown on 429 / Retry-After.
11
+ */
12
+ export declare function budgetedInstagramExecute(execute: RemoteExecute, budget?: InstagramBudget): RemoteExecute;
13
+ /** The status wait's schedule: a read at once, then one a minute (Meta's "once per minute"), and no
14
+ * read past 120 s — the hard cap. */
15
+ export declare const STATUS_POLL_MS = 60000;
16
+ export declare const MAX_STATUS_WAIT_MS = 120000;
17
+ /** A refusal from Meta, with its Graph error code and subcode kept for the caller to read. */
18
+ export declare class InstagramCallError extends Error {
19
+ readonly label: string;
20
+ readonly status: number;
21
+ readonly code?: number;
22
+ readonly subcode?: number;
23
+ constructor(label: string, status: number, body: string, json: Record<string, any>);
24
+ }
25
+ /**
26
+ * Where a container's bytes go, from the `uri` the vendor answered: on Meta's upload host, that
27
+ * absolute URL (a credentialed call to the host the descriptor declares); anywhere else it is the
28
+ * vendor's own base — a twin standing as the vendor — so the anchored `/ig-api-upload/…` path, which the
29
+ * root's own base prefixes. No `uri` at all: Meta's documented form.
30
+ */
31
+ export declare function uploadTarget(uri: unknown, container: string): string;
32
+ /** The container body a stored Reel crosses as. */
33
+ export declare function containerParamsForVendor(fields: Record<string, any>): Record<string, unknown>;
34
+ /** One upload POST carries at most this much: the kernel's executor gives a request 30 s, so a chunk
35
+ * must cross in that time on a modest uplink (8 MiB in 30 s is ~2.3 Mbps). */
36
+ export declare const UPLOAD_CHUNK_BYTES: number;
37
+ /**
38
+ * A container's bytes to the upload host from `from`, in chunks of UPLOAD_CHUNK_BYTES, each POST's
39
+ * `offset` the first byte it carries and `file_size` the whole file (the Content Publishing guide:
40
+ * "offset is set to the first byte being upload"). `onChunk` hears each offset the vendor confirmed, so
41
+ * a retry resumes there. The last POST must answer the documented `{"success":true}`.
42
+ */
43
+ export declare function uploadReel(execute: RemoteExecute, target: string, bytes: Uint8Array, from?: number, onChunk?: (uploaded: number) => Promise<void>): Promise<void>;
44
+ /** Read a container's status_code on the schedule until it is FINISHED; the verdict otherwise. */
45
+ export declare function waitForContainer(execute: RemoteExecute, container: string, wait?: (ms: number) => Promise<void>): Promise<'FINISHED' | 'ERROR' | 'EXPIRED' | 'PUBLISHED' | 'TIMEOUT'>;
46
+ /**
47
+ * The perform adapter (runtime contract R18): what a World's deploy calls for each landed Instagram
48
+ * entry on a twin whose root is real. A seeded account or token is the twin's own record and crosses nothing.
49
+ */
50
+ export declare function performInstagramAction(kernelExecute: RemoteExecute, action: TwinAction, ctx: PerformContext): Promise<PushOutcome>;
51
+ /** The perform, charged to a given budget: the credential's ledger in production (`performInstagramAction`),
52
+ * a throwaway ledger where a verify must not spend the operator's allowance. */
53
+ export declare function performInstagramActionWithin(budget: InstagramBudget, kernelExecute: RemoteExecute, action: TwinAction, ctx: PerformContext, wait?: (ms: number) => Promise<void>, now?: () => number): Promise<PushOutcome>;
54
+ /**
55
+ * The refresh adapter: the credential's professional account and every media it holds, observed into
56
+ * the parent log. Read-side only. A refusal throws.
57
+ */
58
+ export declare function syncInstagramFromRemote(kernelExecute: RemoteExecute, opts?: {
59
+ root?: string;
60
+ origin?: string;
61
+ occurredAt?: string;
62
+ budget?: InstagramBudget;
63
+ credential?: string;
64
+ }): Promise<{
65
+ observed: number;
66
+ users: number;
67
+ media: number;
68
+ }>;
69
+ /** D7's consumer-facing entry point, the same read as the refresh adapter. */
70
+ export declare function syncInstagramFromReal(execute: RemoteExecute, opts?: {
71
+ root?: string;
72
+ occurredAt?: string;
73
+ budget?: InstagramBudget;
74
+ credential?: string;
75
+ }): Promise<{
76
+ observed: number;
77
+ users: number;
78
+ media: number;
79
+ }>;
@@ -0,0 +1,437 @@
1
+ // INSTAGRAM CONNECTOR — the pack's half of the REAL state system (protocol 2), over the kernel's
2
+ // executor. The kernel builds `execute` from the root's origin and its sealed credential; this file
3
+ // holds no credential and imports no network client.
4
+ //
5
+ // PERFORM (twin → Instagram). A landed `instagram.media.publish` is a Reel: its entry names the video
6
+ // by DIGEST (instagram-twin.ts `_video`); a container is staging and not an entry of its own
7
+ // (instagram-media.ts), so the adapter does at Meta what the app did at the twin, as the Content
8
+ // Publishing guide documents it:
9
+ // 1. `POST /{ig-user-id}/media` — media_type=REELS, upload_type=resumable, the caption,
10
+ // share_to_feed, cover_url or thumb_offset, audio_name — answering the container id and its `uri`;
11
+ // 2. `POST` the bytes to that `uri` on rupload.facebook.com in 8 MiB chunks (`offset`, `file_size`;
12
+ // the kernel gives a request 30 s), the container kept from the moment it opens and the offset the
13
+ // vendor confirmed kept after every chunk, so a retry resumes rather than re-opens. The upload
14
+ // host takes the SAME access token as the Graph API, so this is a CREDENTIALED call to a second
15
+ // host: the kernel executor sends the sealed credential there only because this pack's own
16
+ // descriptor declares `{ host: 'rupload.facebook.com', pathPattern: '^/ig-api-upload/' }`
17
+ // (kernel 06ecdd56b), and the URL goes absolute, never presigned. A twin standing as the vendor
18
+ // mints its `uri` under its own base, which IS the root's origin, so there the upload goes as the
19
+ // anchored path `/ig-api-upload/…` the root's own base prefixes.
20
+ // THE CREDENTIAL'S SCHEME: the rupload host documents `Authorization: OAuth <token>`, and the Graph
21
+ // API takes the same scheme (its Resumable Upload guide sends `Authorization: OAuth` to
22
+ // graph.facebook.com), so ONE sealed header — `authorization: OAuth <token>` — serves both hosts.
23
+ // 3. `GET /{container-id}?fields=status_code` until FINISHED — Meta: "We recommend querying a
24
+ // container's status once per minute, for no more than 5 minutes"; this wait reads at 0, 60 and
25
+ // 120 s (a HARD CAP of 120 s) and then fails RETRYABLY: the entry stays deployable and the
26
+ // container id is KEPT, so the retry publishes that container instead of uploading again;
27
+ // 4. `POST /{ig-user-id}/media_publish` with `creation_id` → the media id; then one read of its
28
+ // permalink for the receipt (a failed read never drops the id that already arrived).
29
+ // A container that ends in ERROR refuses the entry when its subcode says the FILE is out of the Reels
30
+ // spec (2207026, or 2207057 for thumb_offset) — the same bytes fail again; any other ERROR fails
31
+ // retryably and the retry opens a fresh container. A container that reads PUBLISHED though this entry
32
+ // never got its media id (the publish answer was lost) is matched to the media it became; if none can
33
+ // be identified the entry is refused, never published twice and never left blocking the queue.
34
+ // A landed `instagram.media.delete` becomes `DELETE /{ig-media-id}`.
35
+ //
36
+ // THE PERFORM AND REFRESH PATHS ARE BUDGETED. The kernel's executor knows nothing of Instagram's
37
+ // limits, so each adapter wraps it (`budgetedInstagramExecute`) in this pack's InstagramBudget ledger,
38
+ // keyed by the sealed credential's fingerprint (`ctx.credential`): every call is charged BEFORE it goes
39
+ // out, a refusal THROWS without calling Meta, and a 429 / Retry-After arms the persisted cooldown. An
40
+ // answer that has already arrived is never dropped: if settling the ledger throws after a 2xx, the
41
+ // answer is returned and the NEXT call meets the cooldown.
42
+ //
43
+ // REFRESH (Instagram → twin). The credential's account (`/me`) and its media (paged), OBSERVED through
44
+ // the kernel. A refused read throws; it is never an empty page folded over state.
45
+ import { assertBudgetGuardIntact, observeResource, RefusedWriteError } from '@volter/world-core';
46
+ import { clearPerformContainer, CONTAINER_LIFETIME_MS, MAX_REEL_BYTES, readInstagramBlob, readPerformContainer, writePerformContainer } from "./instagram-media.js";
47
+ import { InstagramBudget, instagramCallWeight } from "./instagram-budget.js";
48
+ const SERVICE = 'instagram';
49
+ /** The Graph API version every call this adapter makes pins — the newest the twin models. */
50
+ export const INSTAGRAM_VERSION = 'v26.0';
51
+ /** Meta's upload host. */
52
+ export const RUPLOAD_HOST = 'rupload.facebook.com';
53
+ /**
54
+ * The kernel executor, charged to this pack's InstagramBudget: the check RESERVES before the call and
55
+ * throws (InstagramBudgetError) instead of calling when the ceiling, the burst bound or a cooldown says
56
+ * stop; the answer settles the reservation and arms a cooldown on 429 / Retry-After.
57
+ */
58
+ export function budgetedInstagramExecute(execute, budget = new InstagramBudget()) {
59
+ const guard = assertBudgetGuardIntact(budget, InstagramBudget, 'budgetedInstagramExecute');
60
+ return async (request) => {
61
+ const weight = instagramCallWeight(request.method, request.path);
62
+ const reservation = guard.checkBudget(weight);
63
+ const res = await execute(request);
64
+ try {
65
+ guard.recordCall(weight, Object.fromEntries(Object.entries(res.headers ?? {}).map(([k, v]) => [k.toLowerCase(), v])), { status: res.status, reservation });
66
+ }
67
+ catch (error) {
68
+ // the vendor has already answered: a 2xx is kept (the cooldown is persisted; the next call meets it)
69
+ if (res.status < 200 || res.status >= 300)
70
+ throw error;
71
+ }
72
+ return res;
73
+ };
74
+ }
75
+ /** The status wait's schedule: a read at once, then one a minute (Meta's "once per minute"), and no
76
+ * read past 120 s — the hard cap. */
77
+ export const STATUS_POLL_MS = 60_000;
78
+ export const MAX_STATUS_WAIT_MS = 120_000;
79
+ async function call(execute, label, request, expect) {
80
+ const res = await execute(request);
81
+ let json = {};
82
+ if (res.body.trim() !== '') {
83
+ try {
84
+ json = JSON.parse(res.body);
85
+ }
86
+ catch {
87
+ throw new Error(`instagram answered ${label} with a body that is not JSON: HTTP ${res.status} ${res.body.slice(0, 200)}`);
88
+ }
89
+ }
90
+ if (!expect.includes(res.status))
91
+ throw new InstagramCallError(label, res.status, res.body, json);
92
+ return { status: res.status, headers: res.headers, body: res.body, json };
93
+ }
94
+ /** A refusal from Meta, with its Graph error code and subcode kept for the caller to read. */
95
+ export class InstagramCallError extends Error {
96
+ label;
97
+ status;
98
+ code;
99
+ subcode;
100
+ constructor(label, status, body, json) {
101
+ super(`instagram refused ${label}: HTTP ${status} ${body.slice(0, 300)}`);
102
+ this.label = label;
103
+ this.status = status;
104
+ this.name = 'InstagramCallError';
105
+ const e = json.error;
106
+ if (typeof e?.code === 'number')
107
+ this.code = e.code;
108
+ if (typeof e?.error_subcode === 'number')
109
+ this.subcode = e.error_subcode;
110
+ }
111
+ }
112
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
113
+ const graph = (path) => `/${INSTAGRAM_VERSION}${path}`;
114
+ const JSON_TYPE = { 'content-type': 'application/json' };
115
+ /**
116
+ * Where a container's bytes go, from the `uri` the vendor answered: on Meta's upload host, that
117
+ * absolute URL (a credentialed call to the host the descriptor declares); anywhere else it is the
118
+ * vendor's own base — a twin standing as the vendor — so the anchored `/ig-api-upload/…` path, which the
119
+ * root's own base prefixes. No `uri` at all: Meta's documented form.
120
+ */
121
+ export function uploadTarget(uri, container) {
122
+ if (typeof uri === 'string' && uri !== '') {
123
+ let u;
124
+ try {
125
+ u = new URL(uri);
126
+ }
127
+ catch {
128
+ throw new Error(`instagram answered a container uri that is not a URL: ${uri.slice(0, 200)}`);
129
+ }
130
+ if (u.hostname === RUPLOAD_HOST) {
131
+ if (u.protocol !== 'https:' || u.port !== '' || u.username !== '' || u.password !== '')
132
+ throw new Error(`instagram answered an upload uri that is not plain https on its upload host: ${uri.slice(0, 200)}`);
133
+ return u.href;
134
+ }
135
+ const at = u.pathname.indexOf('/ig-api-upload/');
136
+ if (at < 0)
137
+ throw new Error(`instagram answered a container uri with no /ig-api-upload/ path: ${uri.slice(0, 200)}`);
138
+ return u.pathname.slice(at);
139
+ }
140
+ return `https://${RUPLOAD_HOST}/ig-api-upload/${INSTAGRAM_VERSION}/${container}`;
141
+ }
142
+ /** The container body a stored Reel crosses as. */
143
+ export function containerParamsForVendor(fields) {
144
+ return {
145
+ media_type: 'REELS',
146
+ upload_type: 'resumable',
147
+ ...(typeof fields.caption === 'string' ? { caption: fields.caption } : {}),
148
+ share_to_feed: fields.is_shared_to_feed !== false,
149
+ ...(typeof fields._cover_url === 'string' ? { cover_url: fields._cover_url } : {}),
150
+ ...(typeof fields._thumb_offset === 'number' && typeof fields._cover_url !== 'string' ? { thumb_offset: fields._thumb_offset } : {}),
151
+ ...(typeof fields._audio_name === 'string' ? { audio_name: fields._audio_name } : {}),
152
+ ...(typeof fields.is_ai_generated === 'boolean' ? { is_ai_generated: fields.is_ai_generated } : {}),
153
+ };
154
+ }
155
+ /** One upload POST carries at most this much: the kernel's executor gives a request 30 s, so a chunk
156
+ * must cross in that time on a modest uplink (8 MiB in 30 s is ~2.3 Mbps). */
157
+ export const UPLOAD_CHUNK_BYTES = 8 * 1024 * 1024;
158
+ /**
159
+ * A container's bytes to the upload host from `from`, in chunks of UPLOAD_CHUNK_BYTES, each POST's
160
+ * `offset` the first byte it carries and `file_size` the whole file (the Content Publishing guide:
161
+ * "offset is set to the first byte being upload"). `onChunk` hears each offset the vendor confirmed, so
162
+ * a retry resumes there. The last POST must answer the documented `{"success":true}`.
163
+ */
164
+ export async function uploadReel(execute, target, bytes, from = 0, onChunk = async () => undefined) {
165
+ for (let at = from; at < bytes.length;) {
166
+ const end = Math.min(bytes.length, at + UPLOAD_CHUNK_BYTES);
167
+ const res = await call(execute, `rupload at offset ${at}`, {
168
+ method: 'POST', path: target, headers: { offset: String(at), file_size: String(bytes.length), 'content-type': 'application/octet-stream' }, body: bytes.subarray(at, end),
169
+ }, [200, 201]);
170
+ // the offset the host says it holds, when it says one, is what it confirmed
171
+ at = typeof res.json.offset === 'number' && Number.isInteger(res.json.offset) && res.json.offset > at && res.json.offset <= end ? res.json.offset : end;
172
+ if (at === bytes.length) {
173
+ if (res.json.success !== true)
174
+ throw new Error(`instagram's upload host did not confirm the upload: ${res.body.slice(0, 300)}`);
175
+ await onChunk(at);
176
+ return;
177
+ }
178
+ await onChunk(at);
179
+ }
180
+ }
181
+ /** Read a container's status_code on the schedule until it is FINISHED; the verdict otherwise. */
182
+ export async function waitForContainer(execute, container, wait = sleep) {
183
+ for (let waited = 0;; waited += STATUS_POLL_MS) {
184
+ const got = await call(execute, 'container status', { method: 'GET', path: graph(`/${container}?fields=status_code,status`) }, [200]);
185
+ const status = got.json.status_code;
186
+ if (status === 'FINISHED' || status === 'ERROR' || status === 'EXPIRED' || status === 'PUBLISHED')
187
+ return status;
188
+ if (status !== 'IN_PROGRESS')
189
+ throw new Error(`instagram answered container ${container} with an unknown status_code ${JSON.stringify(status)}`);
190
+ if (waited + STATUS_POLL_MS > MAX_STATUS_WAIT_MS)
191
+ return 'TIMEOUT';
192
+ await wait(STATUS_POLL_MS);
193
+ }
194
+ }
195
+ /**
196
+ * The perform adapter (runtime contract R18): what a World's deploy calls for each landed Instagram
197
+ * entry on a twin whose root is real. A seeded account or token is the twin's own record and crosses nothing.
198
+ */
199
+ export async function performInstagramAction(kernelExecute, action, ctx) {
200
+ // one ledger per real credential (the sealed credential's keyed fingerprint), as X, YouTube and
201
+ // LinkedIn key theirs: two World roots sealed with one credential spend one allowance
202
+ return performInstagramActionWithin(new InstagramBudget(ctx.credential !== undefined ? { token: ctx.credential } : ctx.root !== undefined ? { root: ctx.root } : {}), kernelExecute, action, ctx);
203
+ }
204
+ /** The perform, charged to a given budget: the credential's ledger in production (`performInstagramAction`),
205
+ * a throwaway ledger where a verify must not spend the operator's allowance. */
206
+ export async function performInstagramActionWithin(budget, kernelExecute, action, ctx, wait = sleep, now = Date.now) {
207
+ const execute = budgetedInstagramExecute(kernelExecute, budget);
208
+ const fields = { ...(action.fields ?? {}) };
209
+ const subjectId = action.subject?.id ?? '';
210
+ if ((action.operation ?? '').startsWith('instagram.twin.')) {
211
+ return { externalId: ctx.resolve(action.subject?.type ?? 'ig_user', subjectId), data: { performed: false, reason: `${action.operation} is the twin's own record — nothing at Instagram to write` } };
212
+ }
213
+ if (action.operation === 'instagram.media.delete') {
214
+ const target = ctx.resolve('media', subjectId);
215
+ const res = await call(execute, 'media DELETE', { method: 'DELETE', path: graph(`/${encodeURIComponent(target)}`) }, [200]);
216
+ if (res.json.success !== true)
217
+ throw new Error(`instagram did not confirm deleting ${target}: ${res.body.slice(0, 200)}`);
218
+ return { externalId: target, data: { deleted: true } };
219
+ }
220
+ if (action.operation !== 'instagram.media.publish')
221
+ throw new Error(`instagram cannot perform ${action.operation ?? 'this entry'} on ${subjectId}: no Instagram request expresses it`);
222
+ const video = fields._video;
223
+ const owner = ctx.resolve('ig_user', String(fields.owner?.id ?? ''));
224
+ if (!/^\d+$/.test(owner))
225
+ throw new RefusedWriteError('instagram.media.owner', `media ${subjectId} names no IG user`, action.id);
226
+ const bytes = video?.sha256 ? await readInstagramBlob(video.sha256, ctx.root) : null;
227
+ if (!bytes)
228
+ throw new Error(`instagram cannot perform ${subjectId}: the bytes of its Reel are not on this twin's blob seam`);
229
+ if (bytes.length > MAX_REEL_BYTES)
230
+ throw new RefusedWriteError('instagram.reel_spec', `a ${bytes.length}-byte Reel is over the 300MB maximum`, action.id);
231
+ // A retry of an entry whose Reel already reached a container at Meta continues THAT container: it
232
+ // resumes the upload at the offset the vendor confirmed, or waits on it, or publishes it.
233
+ let kept = await readPerformContainer(action.id, ctx.root);
234
+ if (kept && (kept.sha256 !== video.sha256 || now() - kept.created_ms >= CONTAINER_LIFETIME_MS || !validTarget(kept.target, kept.container))) {
235
+ await clearKept(action.id, ctx.root);
236
+ kept = null;
237
+ }
238
+ const reopened = kept !== null;
239
+ if (!kept) {
240
+ const created = await call(execute, 'media container CREATE', {
241
+ method: 'POST', path: graph(`/${owner}/media`), headers: JSON_TYPE, body: JSON.stringify(containerParamsForVendor(fields)),
242
+ }, [200]);
243
+ const id = created.json.id;
244
+ if (typeof id !== 'string' || !/^\d+$/.test(id))
245
+ throw new Error(`instagram answered the container CREATE without a container id: ${created.body.slice(0, 200)}`);
246
+ // kept at once: a retry after this point never opens a second container for this entry
247
+ // two instants: the local one counts the 24-hour lifetime; the VENDOR's (its answer's HTTP Date, the
248
+ // local clock only when the answer carries none) is what a lost publish is matched against
249
+ const served = Date.parse(headerOf(created.headers, 'date') ?? '');
250
+ kept = { container: id, sha256: video.sha256, created_ms: now(), vendor_opened_ms: Number.isFinite(served) ? served : now(), uploaded: 0, target: uploadTarget(created.json.uri, id) };
251
+ await writePerformContainer(action.id, kept, ctx.root);
252
+ }
253
+ const record = kept;
254
+ if (record.uploaded < bytes.length) {
255
+ // resuming: this container was opened by an earlier attempt, whose last chunk may have reached the
256
+ // host without its answer reaching here — so the host may hold more than `uploaded` says
257
+ const resumed = reopened;
258
+ try {
259
+ await uploadReel(execute, record.target, bytes, record.uploaded, async (uploaded) => { record.uploaded = uploaded; await writePerformContainer(action.id, record, ctx.root); });
260
+ }
261
+ catch (error) {
262
+ // A RESUMED POST the host refused with 400 may hold another offset than the one confirmed here: a
263
+ // chunk arrived whose answer did not. If that chunk was the last, the container has the whole file
264
+ // and reads FINISHED (or ERROR / PUBLISHED): go on with it. Otherwise it is abandoned to expire and
265
+ // the next retry opens a fresh one — which a 400 for another cause (a bad credential) also costs:
266
+ // one container per two retries, the price of not reading Meta's undocumented refusal wording. A
267
+ // throttle (429) or any other status keeps the container as it is.
268
+ if (!(resumed && error instanceof InstagramCallError && error.status === 400))
269
+ throw error;
270
+ const now1 = await call(execute, 'container status', { method: 'GET', path: graph(`/${record.container}?fields=status_code`) }, [200]);
271
+ if (now1.json.status_code === 'EXPIRED') {
272
+ await clearKept(action.id, ctx.root);
273
+ throw error;
274
+ }
275
+ // FINISHED / ERROR / PUBLISHED: the whole file is there. IN_PROGRESS: it may be (Meta processing the
276
+ // whole file) or not (a piece short) — wait on it, and if the wait ends IN_PROGRESS, give it up
277
+ record.uploaded = bytes.length;
278
+ if (now1.json.status_code === 'IN_PROGRESS')
279
+ record.unsure = true;
280
+ await writePerformContainer(action.id, record, ctx.root);
281
+ }
282
+ }
283
+ const container = record.container;
284
+ const verdict = await waitForContainer(execute, container, wait);
285
+ if (verdict === 'ERROR') {
286
+ const got = await call(execute, 'container status', { method: 'GET', path: graph(`/${container}?fields=status_code,status`) }, [200]).catch(() => null);
287
+ const subcode = String(got?.json.status ?? '');
288
+ await clearKept(action.id, ctx.root);
289
+ // the file itself is out of the Reels spec (2207026) or its thumb_offset is (2207057): the same bytes
290
+ // fail again, so the entry is refused; any other ERROR ("Generate a new container ... try again")
291
+ // fails retryably and the retry opens a fresh container
292
+ if (/2207026|2207057/.test(subcode))
293
+ throw new RefusedWriteError('instagram.reel_spec', `Meta could not process the Reel (container ${container}, status ${subcode})`, action.id);
294
+ throw new Error(`instagram could not process the Reel (container ${container}, status ${subcode || 'ERROR'}); a retry uploads it to a new container`);
295
+ }
296
+ if (verdict === 'EXPIRED') {
297
+ await clearKept(action.id, ctx.root);
298
+ throw new Error(`instagram container ${container} expired before it was published; a retry uploads the Reel again`);
299
+ }
300
+ if (verdict === 'PUBLISHED') {
301
+ // this entry's publish reached Meta but its answer did not reach here: find the media it became
302
+ const found = await findPublished(execute, owner, fields, record.vendor_opened_ms ?? record.created_ms);
303
+ await clearKept(action.id, ctx.root);
304
+ if (found)
305
+ return { externalId: found.id, ...(found.permalink ? { url: found.permalink } : {}), data: { _vendor_container: container } };
306
+ throw new RefusedWriteError('instagram.publish_answer_lost', `container ${container} is PUBLISHED at Meta, but its media could not be identified (a caption-less Reel, or not exactly one Reel with this caption since the container opened); not publishing it twice`, action.id);
307
+ }
308
+ if (verdict === 'TIMEOUT') {
309
+ if (record.unsure) {
310
+ await clearKept(action.id, ctx.root);
311
+ throw new Error(`instagram container ${container} was still IN_PROGRESS after ${MAX_STATUS_WAIT_MS / 1000}s after a refused resume: it may hold only part of the file; a retry uploads to a new container`);
312
+ }
313
+ throw new Error(`instagram container ${container} was still IN_PROGRESS after ${MAX_STATUS_WAIT_MS / 1000}s; kept — a retry publishes it without uploading again`);
314
+ }
315
+ const published = await call(execute, 'media_publish', {
316
+ method: 'POST', path: graph(`/${owner}/media_publish`), headers: JSON_TYPE, body: JSON.stringify({ creation_id: container }),
317
+ }, [200]);
318
+ const mediaId = published.json.id;
319
+ if (typeof mediaId !== 'string' || !/^\d+$/.test(mediaId))
320
+ throw new Error(`instagram answered media_publish without a media id: ${published.body.slice(0, 200)}`);
321
+ // the id has arrived: nothing after this line may drop it
322
+ await clearKept(action.id, ctx.root);
323
+ let permalink;
324
+ try {
325
+ const got = await call(execute, 'media GET', { method: 'GET', path: graph(`/${mediaId}?fields=permalink`) }, [200]);
326
+ if (typeof got.json.permalink === 'string')
327
+ permalink = got.json.permalink;
328
+ }
329
+ catch { /* the publish stands */ }
330
+ return { externalId: mediaId, ...(permalink ? { url: permalink } : {}), data: { _vendor_container: container } };
331
+ }
332
+ /** Drop an entry's kept container; a failure to drop it never fails what already happened. */
333
+ async function clearKept(actionId, root) {
334
+ try {
335
+ await clearPerformContainer(actionId, root);
336
+ }
337
+ catch { /* the record is overwritten or expires with its container */ }
338
+ }
339
+ /** The media a PUBLISHED container became, when its publish answer was lost: the one media of the
340
+ * account, among its newest, with this entry's caption and a timestamp after the container opened. */
341
+ async function findPublished(execute, owner, fields, openedMs) {
342
+ // a caption-less Reel cannot be told from any other caption-less post: never guessed
343
+ const caption = typeof fields.caption === 'string' && fields.caption.trim() !== '' ? fields.caption : undefined;
344
+ if (caption === undefined)
345
+ return undefined;
346
+ // a read that fails throws (the entry retries); only a read that succeeded can refuse
347
+ const res = await call(execute, 'media list (a lost publish)', { method: 'GET', path: graph(`/${owner}/media?fields=id,caption,timestamp,permalink,media_product_type&limit=25`) }, [200]);
348
+ if (!Array.isArray(res.json.data))
349
+ throw new Error(`instagram answered the media list with no data: ${res.body.slice(0, 200)}`);
350
+ // IG Media: "Captions don't include the @ symbol unless the app user is also able to perform
351
+ // admin-equivalent tasks" — compared with every @ removed on both sides
352
+ const bare = (c) => String(c ?? '').replace(/@/g, '');
353
+ const hits = res.json.data.filter((m) => m.media_product_type === 'REELS' && bare(m.caption) === bare(caption)
354
+ && Date.parse(String(m.timestamp).replace(/([+-]\d\d)(\d\d)$/, '$1:$2')) >= openedMs - 60_000);
355
+ return hits.length === 1 && typeof hits[0].id === 'string' ? { id: hits[0].id, ...(typeof hits[0].permalink === 'string' ? { permalink: hits[0].permalink } : {}) } : undefined;
356
+ }
357
+ /** A kept target read back from the annex, held to what uploadTarget would answer: Meta's upload host
358
+ * over plain https, or the anchored twin path. */
359
+ function validTarget(target, container) {
360
+ if (!/^\d+$/.test(container))
361
+ return false;
362
+ if (/^https:\/\//i.test(target)) {
363
+ try {
364
+ return uploadTarget(target, container) === target && new URL(target).pathname.endsWith(`/${container}`);
365
+ }
366
+ catch {
367
+ return false;
368
+ }
369
+ }
370
+ return new RegExp(`^/ig-api-upload/(?:v\\d+\\.\\d+/)?${container}$`).test(target);
371
+ }
372
+ function headerOf(headers, name) {
373
+ for (const [k, v] of Object.entries(headers ?? {}))
374
+ if (k.toLowerCase() === name)
375
+ return v;
376
+ return undefined;
377
+ }
378
+ // ── refresh ──────────────────────────────────────────────────────────────────────────────────
379
+ /** How many pages of media a refresh reads before it refuses to go on. */
380
+ const MAX_PAGES = 50;
381
+ const USER_FIELDS = 'id,username,name,biography,website,followers_count,follows_count,profile_picture_url';
382
+ const MEDIA_FIELDS = 'id,media_type,media_product_type,media_url,permalink,caption,timestamp,thumbnail_url,shortcode,owner,is_shared_to_feed,is_comment_enabled,comments_count,like_count';
383
+ let lastPollMs = 0;
384
+ /** A MOVING observation instant, strictly increasing in the process — never a pinned constant. */
385
+ function pollTimestamp() {
386
+ const t = Date.now();
387
+ lastPollMs = t > lastPollMs ? t : lastPollMs + 1;
388
+ return new Date(lastPollMs).toISOString();
389
+ }
390
+ /**
391
+ * The refresh adapter: the credential's professional account and every media it holds, observed into
392
+ * the parent log. Read-side only. A refusal throws.
393
+ */
394
+ export async function syncInstagramFromRemote(kernelExecute, opts = {}) {
395
+ const execute = budgetedInstagramExecute(kernelExecute, opts.budget ?? new InstagramBudget(opts.credential !== undefined ? { token: opts.credential } : opts.root !== undefined ? { root: opts.root } : {}));
396
+ const at = opts.occurredAt ?? pollTimestamp();
397
+ const observe = (type, id, f) => observeResource(SERVICE, { type, id, fields: f }, { ...(opts.root !== undefined ? { root: opts.root } : {}), at });
398
+ const me = await call(execute, 'IG User GET me', { method: 'GET', path: graph(`/me?fields=${USER_FIELDS}`) }, [200]);
399
+ const u = me.json;
400
+ if (typeof u.id !== 'string' || typeof u.username !== 'string')
401
+ throw new Error(`instagram answered /me without its id and username: ${me.body.slice(0, 200)}`);
402
+ const userFields = { username: u.username };
403
+ for (const k of ['name', 'biography', 'website', 'profile_picture_url'])
404
+ if (typeof u[k] === 'string')
405
+ userFields[k] = u[k];
406
+ for (const k of ['followers_count', 'follows_count'])
407
+ if (typeof u[k] === 'number')
408
+ userFields[k] = u[k];
409
+ observe('ig_user', u.id, userFields);
410
+ let media = 0;
411
+ let after;
412
+ for (let page = 0;; page += 1) {
413
+ if (page >= MAX_PAGES)
414
+ throw new Error(`instagram media of ${u.id} exceeded ${MAX_PAGES} pages; refusing to page further`);
415
+ const res = await call(execute, `media list ${u.id}`, { method: 'GET', path: graph(`/${u.id}/media?fields=${MEDIA_FIELDS}&limit=100${after ? `&after=${encodeURIComponent(after)}` : ''}`) }, [200]);
416
+ const data = res.json.data;
417
+ if (!Array.isArray(data))
418
+ throw new Error(`instagram answered the media list with no data: ${res.body.slice(0, 200)}`);
419
+ for (const m of data) {
420
+ if (typeof m.id !== 'string' || !/^\d+$/.test(m.id))
421
+ throw new Error(`instagram answered a media with no id: ${JSON.stringify(m).slice(0, 200)}`);
422
+ const { id, username: _username, ...rest } = m;
423
+ observe('media', id, rest);
424
+ media += 1;
425
+ }
426
+ const next = res.json.paging?.next;
427
+ const cursor = res.json.paging?.cursors?.after;
428
+ if (typeof next !== 'string' || typeof cursor !== 'string' || data.length === 0)
429
+ break;
430
+ after = cursor;
431
+ }
432
+ return { observed: 1 + media, users: 1, media };
433
+ }
434
+ /** D7's consumer-facing entry point, the same read as the refresh adapter. */
435
+ export async function syncInstagramFromReal(execute, opts = {}) {
436
+ return syncInstagramFromRemote(execute, opts);
437
+ }
@@ -0,0 +1,33 @@
1
+ export type InstagramResponse = {
2
+ status: number;
3
+ body: unknown;
4
+ headers?: Record<string, string>;
5
+ };
6
+ export declare function graphError(status: number, code: number, message: string, extra?: {
7
+ subcode?: number;
8
+ type?: string;
9
+ title?: string;
10
+ userMsg?: string;
11
+ transient?: boolean;
12
+ }): InstagramResponse;
13
+ /** No token at all. (Code 104 and its wording are the Graph API's long-standing answer; not in the
14
+ * pages this build read — `instagram.errors.message_wording`.) */
15
+ export declare const noToken: () => InstagramResponse;
16
+ /** A token the twin never issued. */
17
+ export declare const invalidToken: () => InstagramResponse;
18
+ /** A token without the permission this edge needs (Graph code 10, "API Permission Denied"). */
19
+ export declare const permissionDenied: (what: string) => InstagramResponse;
20
+ /** An unknown node or edge (Graph's "Unsupported get request" family, code 100 subcode 33). */
21
+ export declare const unsupported: (method: string, id: string) => InstagramResponse;
22
+ /** A path the Graph API does not know. */
23
+ export declare const unknownPath: (path: string) => InstagramResponse;
24
+ export declare const invalidParam: (message: string, subcode?: number) => InstagramResponse;
25
+ export declare const missingParam: (name: string) => InstagramResponse;
26
+ /** Real Instagram Platform surface this twin does not model: refused by name, never answered as a success. */
27
+ export declare const unmodeled: (what: string) => InstagramResponse;
28
+ export declare const readOnlyRefusal: () => InstagramResponse;
29
+ export declare function ok(body: unknown, status?: number): InstagramResponse;
30
+ export declare const ruploadOk: () => InstagramResponse;
31
+ /** The rupload failure envelope. `type` names are the twin's where the guide gives only its one
32
+ * sample (`ProcessingFailedError`, "unauthorized user request"). */
33
+ export declare function ruploadFailure(status: number, type: string, message: string, retriable?: boolean): InstagramResponse;