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