@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
package/package.json ADDED
@@ -0,0 +1,57 @@
1
+ {
2
+ "name": "@volter/twin-instagram",
3
+ "version": "0.1.0",
4
+ "description": "Local Instagram twin (a professional account's Reels: resumable containers, the rupload upload, status_code, media_publish) with an instagram.com profile mirror, built on @volter/world-core.",
5
+ "author": "Volter (https://github.com/volter-ai)",
6
+ "license": "Apache-2.0",
7
+ "files": [
8
+ "src",
9
+ "README.md",
10
+ "LICENSE",
11
+ "!**/*.test.ts",
12
+ "!**/*.test.tsx",
13
+ "dist"
14
+ ],
15
+ "repository": {
16
+ "type": "git",
17
+ "url": "git+https://github.com/volter-ai/twin.git",
18
+ "directory": "packages/twin/instagram"
19
+ },
20
+ "homepage": "https://github.com/volter-ai/twin/tree/main/packages/twin/instagram#readme",
21
+ "type": "module",
22
+ "exports": {
23
+ ".": {
24
+ "types": "./dist/src/index.d.ts",
25
+ "default": "./dist/src/index.js"
26
+ }
27
+ },
28
+ "bin": {
29
+ "world-instagram": "dist/src/cli.js"
30
+ },
31
+ "scripts": {
32
+ "test": "bun test src/*.test.ts",
33
+ "typecheck": "tsc --noEmit",
34
+ "build": "node ../../../scripts/publish/build.mjs",
35
+ "prepack": "node ../../../scripts/publish/prepare-publish.mjs prepack",
36
+ "postpack": "node ../../../scripts/publish/prepare-publish.mjs postpack"
37
+ },
38
+ "peerDependencies": {
39
+ "@volter/world-core": "2.0.0"
40
+ },
41
+ "devDependencies": {
42
+ "@types/bun": "^1.2.20",
43
+ "@types/node": "^24.0.0",
44
+ "@types/react": "^19.2.17",
45
+ "@types/react-dom": "^19.2.3",
46
+ "@volter/world-core": "2.0.0",
47
+ "@volter/world-tooling": "0.1.0",
48
+ "typescript": "^5.9.0"
49
+ },
50
+ "engines": {
51
+ "node": ">=22.3"
52
+ },
53
+ "dependencies": {
54
+ "react": "^19.2.7",
55
+ "react-dom": "^19.2.7"
56
+ }
57
+ }
package/src/cli.ts ADDED
@@ -0,0 +1,28 @@
1
+ #!/usr/bin/env node
2
+ import { keepProcessAlive } from '@volter/world-core/lifecycle';
3
+ import { hasFlag, optionValue } from '@volter/world-core/args';
4
+ import { createInstagramTwinServer } from './instagram-server.ts';
5
+ import { createInstagramMirrorServer } from './instagram-mirror-ui.ts';
6
+
7
+ const [cmd, ...rest] = process.argv.slice(2);
8
+ const port = Number(optionValue(rest, '--port', '0')) || undefined;
9
+ const root = optionValue(rest, '--root') || undefined;
10
+ const readOnly = hasFlag(rest, '--read-only');
11
+
12
+ if (cmd === 'serve') {
13
+ const server = await createInstagramTwinServer({ readOnly, ...(root ? { root } : {}), ...(port ? { port } : {}) });
14
+ process.stdout.write(`instagram twin${readOnly ? ' [read-only]' : ''} at http://127.0.0.1:${server.port}\n`);
15
+ await keepProcessAlive();
16
+ } else if (cmd === 'mirror') {
17
+ const s = await createInstagramMirrorServer({ readOnly, ...(root ? { root } : {}), ...(port ? { port } : {}) });
18
+ process.stdout.write(`instagram mirror UI (instagram.com: log in, profile, Reels grid, Reel viewer) at ${s.url}\n`);
19
+ await keepProcessAlive();
20
+ } else if (cmd === 'conformance') {
21
+ // Lazy — the conformance module is DEV-ONLY and must not be reachable from the runtime entrypoints.
22
+ const { checkInstagramConformance } = await import('./instagram-conformance.ts');
23
+ const report = await checkInstagramConformance({ ...(root ? { root } : {}) });
24
+ process.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
25
+ if (!report.ok) process.exitCode = 1;
26
+ } else {
27
+ process.stdout.write('Usage: world-instagram serve|mirror|conformance [--port N] [--root DIR] [--read-only]\n');
28
+ }
package/src/index.ts ADDED
@@ -0,0 +1,117 @@
1
+ export { GRAPH_VERSIONS, handleInstagramTwinRequest, igTimestamp, LATEST_VERSION, projectMedia, PUBLISH_QUOTA_TOTAL, reelVideoUrl, shortcodeFor, tokenDigest, usableVersions } from './instagram-twin.ts';
2
+ export type { InstagramRequest } from './instagram-twin.ts';
3
+ export type { InstagramResponse } from './instagram-errors.ts';
4
+ export { createInstagramTwinFetch, createInstagramTwinServer, RANGE_CAP, type InstagramTwinFetchOptions } from './instagram-server.ts';
5
+ export {
6
+ budgetedInstagramExecute,
7
+ containerParamsForVendor,
8
+ INSTAGRAM_VERSION,
9
+ InstagramCallError,
10
+ MAX_STATUS_WAIT_MS,
11
+ performInstagramAction,
12
+ performInstagramActionWithin,
13
+ STATUS_POLL_MS,
14
+ syncInstagramFromReal,
15
+ syncInstagramFromRemote,
16
+ UPLOAD_CHUNK_BYTES,
17
+ uploadReel,
18
+ uploadTarget,
19
+ waitForContainer,
20
+ } from './instagram-connector.ts';
21
+ export {
22
+ buildInstagramMirrorClient,
23
+ captionSegments,
24
+ compactCount,
25
+ createInstagramMirrorServer,
26
+ instagramMirrorHtml,
27
+ instagramMirrorStyles,
28
+ MIRROR_VERSION,
29
+ profileFromRead,
30
+ reelRoute,
31
+ tabReels,
32
+ timeAgo,
33
+ } from './instagram-mirror-ui.ts';
34
+ export type { IgRow, Segment } from './instagram-mirror-ui.ts';
35
+ // The client-side rate budget the perform and refresh adapters charge (instagram-budget.ts says how the
36
+ // numbers were chosen). The mechanism is the kernel's; these are this vendor's numbers.
37
+ export {
38
+ INSTAGRAM_BUDGET_BURST_CEILING,
39
+ INSTAGRAM_BUDGET_CEILING,
40
+ INSTAGRAM_BUDGET_MAX_RETRY_AFTER_S,
41
+ INSTAGRAM_BUDGET_WINDOW_MS,
42
+ INSTAGRAM_CALL_WEIGHTS,
43
+ INSTAGRAM_RATE_BUDGET,
44
+ InstagramBudget,
45
+ InstagramBudgetError,
46
+ instagramBudgetPath,
47
+ instagramCallWeight,
48
+ } from './instagram-budget.ts';
49
+
50
+ import { registerPack, type TwinPack } from '@volter/world-core';
51
+ import { performInstagramAction, syncInstagramFromRemote } from './instagram-connector.ts';
52
+ import { INSTAGRAM_RATE_BUDGET as RATE_BUDGET } from './instagram-budget.ts';
53
+
54
+ /**
55
+ * The Graph paths THIS pack serves, as a RegExp SOURCE for the descriptor's `hosts` path rule: an
56
+ * optional `/vNN.N` version, then `/me` or a numeric node (an IG User, an IG Media or an IG Container)
57
+ * with at most one of the edges modelled here — and the twin-only control prefix. ANCHORED, so
58
+ * `/{id}/insights` or `/{page-id}/feed` stays unclaimed and refuses loudly at the real vendor. A bare
59
+ * numeric node is claimed whatever it names (the path cannot tell an IG id from a Page's); a node the
60
+ * twin does not hold answers Meta's own "Unsupported get request".
61
+ */
62
+ const GRAPH_PATHS = '^(?:/v\\d+\\.\\d+)?/(?:me|\\d{1,25})(?:/(?:media|media_publish|content_publishing_limit))?/?$|^/_twin/';
63
+
64
+ export const pack: TwinPack = {
65
+ vendor: 'instagram',
66
+ // The SAME object instagram-budget.ts declares at module load — one source of truth.
67
+ rateBudget: RATE_BUDGET,
68
+ transport: 'rest',
69
+ // PROTOCOL 2 (docs/contributing/architecture.md#protocol-2-the-pack-is-a-plugin): the pack is a
70
+ // plugin — its wire, its tree, and its half of the real state system. Born on it.
71
+ protocol: '2',
72
+ archetype: 'crud',
73
+ bin: 'world-instagram',
74
+ resources: ['ig_user', 'media'],
75
+ specSource: 'Instagram Platform docs (developers.facebook.com/docs/instagram-platform, Graph API v26.0), read 2026-09-27: Content Publishing (incl. Resumable Upload Session and Reels), the IG User, IG User Media, IG Container, IG User Media Publish, IG Media and IG User Content Publishing Limit references, Error Codes; and the Graph API guides for Versioning, Handle Errors and Resumable Upload. Meta publishes no machine-readable spec for this surface.',
76
+ description: "Instagram twin — a professional account's Reels through the Graph API's content publishing flow (a resumable container, the rupload.facebook.com upload, the status_code lifecycle, media_publish), the account and media nodes, the publishing limit, and an instagram.com profile mirror with a Reels grid and a Reel viewer; everything else refuses by name.",
77
+ // An account's Reels barely move and Meta's limits are daily: hourly, and on demand.
78
+ refresh: { every: '1h', webhook: false, onDemand: { atMost: '60s' } },
79
+ stateSystem: { perform: performInstagramAction, refresh: syncInstagramFromRemote },
80
+ // The round trip: the professional account and the token the Facebook Login leg would have minted
81
+ // (the vendor has no create-from-nothing for either), then a Reels container on that account — staging
82
+ // the twin holds outside the log, as Meta holds it outside the account's media.
83
+ roundTrip: [
84
+ { method: 'POST', path: '/_twin/users', body: { id: '17841400000000001', username: 'round.trip', name: 'Round Trip', followers_count: 7 } },
85
+ { method: 'POST', path: '/_twin/tokens', body: { token: 'round-trip-token', user: '17841400000000001', permissions: ['instagram_basic', 'instagram_content_publish', 'pages_read_engagement'] } },
86
+ {
87
+ method: 'POST', path: '/v26.0/17841400000000001/media',
88
+ headers: { authorization: 'Bearer round-trip-token' },
89
+ body: { media_type: 'REELS', upload_type: 'resumable', caption: 'round trip' },
90
+ },
91
+ ],
92
+ parityOrigin: 'http://twin',
93
+ // the write handler and the refresh adapter store the same account shape (scripts/shape-parity.test.ts)
94
+ shapeParity: 'held',
95
+
96
+ // ADOPTION. Meta publishes no official JavaScript client for the Instagram Platform (the
97
+ // facebook-nodejs-business-sdk is the Marketing API's, and claiming it would attribute ads traffic
98
+ // to this twin). INSTAGRAM is the stem of INSTAGRAM_ACCESS_TOKEN / INSTAGRAM_USER_ID et al.
99
+ adoption: {
100
+ envStems: ['INSTAGRAM'],
101
+ },
102
+ // INTERCEPTION. graph.facebook.com (Facebook Login — the host the resumable upload is documented on)
103
+ // and graph.instagram.com (Instagram Login), both path-scoped to the nodes and edges above;
104
+ // rupload.facebook.com only for `/ig-api-upload/` — the upload host, which takes the SAME token, so
105
+ // the kernel executor sends the sealed credential there only because it is declared here as an exact
106
+ // host with this path; scontent.cdninstagram.com only for the CDN paths the twin mints for a Reel,
107
+ // its stand-in thumbnail and a profile picture.
108
+ hosts: [
109
+ { host: 'graph.facebook.com', pathPattern: GRAPH_PATHS },
110
+ { host: 'graph.instagram.com', pathPattern: GRAPH_PATHS },
111
+ { host: 'rupload.facebook.com', pathPattern: '^/ig-api-upload/' },
112
+ { host: 'scontent.cdninstagram.com', pathPattern: '^/(?:o1/v/t16/f2/m86/|v/t51\\.(?:71878-15|2885-19)/)' },
113
+ ],
114
+ endpointEnvNone: 'Meta ships no Instagram Platform client that reads a base-URL environment variable: apps call https://graph.facebook.com/<version>/… and the rupload host by constant, so a World reaches them through the hosts above; inventing an INSTAGRAM_BASE_URL the app never reads would report coverage the app does not have.',
115
+ };
116
+
117
+ registerPack(pack);
@@ -0,0 +1,130 @@
1
+ // Instagram's CLIENT-SIDE RATE BUDGET — this pack's DECLARATION (the numbers) plus the thin typed
2
+ // bindings the perform and refresh adapters use. The MECHANISM — the durable token-keyed ledger, the
3
+ // rolling window, reserve-under-lock, the Retry-After / 429 cooldown, fail-CLOSED on a corrupt ledger
4
+ // — lives ONCE in the kernel (`@volter/world-core` → rateBudget.ts).
5
+ //
6
+ // ── WHAT META PUBLISHES (developers.facebook.com/docs/instagram-platform, fetched 2026-09-27) ──
7
+ // • Content Publishing guide, "Rate Limit": "Instagram accounts are limited to 100 API-published
8
+ // posts within a 24-hour moving period … enforced on the POST /<IG_ID>/media_publish endpoint".
9
+ // • IG User Media Publish reference: "An Instagram professional account can only publish 50 posts
10
+ // within a 24 hour moving period"; the Content Publishing Limit reference reports quota_total
11
+ // "(currently 50)" over quota_duration 86400. THE STRICTER, 50, IS THE FIGURE THIS BUDGET KEEPS.
12
+ // • IG User Media reference: "An Instagram account can only create 400 containers within a rolling
13
+ // 24 hour period".
14
+ // • The general Graph API call allowance is a Business Use Case formula over the account's
15
+ // impressions — no scalar a client can transcribe.
16
+ //
17
+ // ── HOW THE DECLARATION HOLDS THOSE DAILY FIGURES ──────────────────────────────────────────
18
+ // The kernel's longest window is ONE HOUR, so a daily limit is spread over the day and rounded DOWN
19
+ // (the YouTube pack's method): 250 weighted units per rolling hour, with
20
+ // a publish (POST /{id}/media_publish) 100 units → at most 2 an hour = 48 a day ≤ 50
21
+ // a container (POST /{id}/media) 16 units → at most 15 an hour = 360 a day ≤ 400
22
+ // an upload POST to rupload (8 MiB chunk) 1 unit (a 300 MB Reel is 36 chunks)
23
+ // a delete 8 units
24
+ // a read (status, node, list, limit) 1 unit
25
+ // anything else 6 units
26
+ // A client that spends this flat out for a whole day still lands under both documented limits.
27
+ //
28
+ // ── THE BURST SUB-CEILING ────────────────────────────────────────────────────────────────────
29
+ // 180 units in any 60 s, SET BY THE PERFORM: one whole perform of the largest Reel inside a minute
30
+ // (container 16 + 36 upload chunks + three status reads + publish 100 = 155) with room to spare. A burst
31
+ // below that would refuse the publish of a Reel Meta finished quickly; the guard would be broken, not
32
+ // tighter. The default weight (6, for a call no rule prices — the connector makes none) is then chosen so
33
+ // the burst is 30 calls at it, the kernel fallback's own, so no burst anchor is claimed.
34
+ import {
35
+ declareRateBudget,
36
+ rateBudgetPath,
37
+ rateBudgetWeight,
38
+ RateBudget,
39
+ type RateBudgetDeclaration,
40
+ type RateBudgetOptions,
41
+ type RateBudgetReservation,
42
+ type RateBudgetSnapshot,
43
+ } from '@volter/world-core';
44
+
45
+ const VENDOR = 'instagram';
46
+
47
+ /** Rolling window, in ms — the longest the kernel's ledger keeps (Meta's windows are 24 hours). */
48
+ export const INSTAGRAM_BUDGET_WINDOW_MS = 60 * 60_000;
49
+ /** Weighted units per window: the daily publish and container limits spread over the day, rounded down. */
50
+ export const INSTAGRAM_BUDGET_CEILING = 250;
51
+ /** Weighted units in any 60 s: 30 calls at the default weight (the fallback's burst), one whole Reel perform. */
52
+ export const INSTAGRAM_BUDGET_BURST_CEILING = 180;
53
+ /** Seconds. A Retry-After above this means the account or app is throttled for the day — fail loudly. */
54
+ export const INSTAGRAM_BUDGET_MAX_RETRY_AFTER_S = 3600;
55
+
56
+ export const INSTAGRAM_CALL_WEIGHTS = {
57
+ /** media_publish — the account speaking in public, 50 of them a day. */
58
+ publish: 100,
59
+ /** A container, 400 of them a day. */
60
+ container: 16,
61
+ /** A delete. */
62
+ delete: 8,
63
+ /** One rupload POST (an 8 MiB chunk at most). */
64
+ upload: 1,
65
+ /** A read: a container's status, a node, the media list, the publishing limit. */
66
+ read: 1,
67
+ /** Anything unnamed. */
68
+ other: 6,
69
+ } as const;
70
+
71
+ /** THE PACK'S DECLARATION — pure data, the only Instagram-specific thing in the whole budget. */
72
+ export const INSTAGRAM_RATE_BUDGET: RateBudgetDeclaration = {
73
+ windowMs: INSTAGRAM_BUDGET_WINDOW_MS,
74
+ ceiling: INSTAGRAM_BUDGET_CEILING,
75
+ burstCeiling: INSTAGRAM_BUDGET_BURST_CEILING,
76
+ defaultWeight: INSTAGRAM_CALL_WEIGHTS.other,
77
+ maxRetryAfterSeconds: INSTAGRAM_BUDGET_MAX_RETRY_AFTER_S,
78
+ rules: [
79
+ { match: '^POST (/v\\d+\\.\\d+)?/\\d+/media_publish$', weight: INSTAGRAM_CALL_WEIGHTS.publish },
80
+ { match: '^POST (/v\\d+\\.\\d+)?/\\d+/media$', weight: INSTAGRAM_CALL_WEIGHTS.container },
81
+ { match: '^DELETE (/v\\d+\\.\\d+)?/\\d+$', weight: INSTAGRAM_CALL_WEIGHTS.delete },
82
+ { match: '^POST (rupload\\.facebook\\.com)?/ig-api-upload/', weight: INSTAGRAM_CALL_WEIGHTS.upload },
83
+ { match: '^GET ', weight: INSTAGRAM_CALL_WEIGHTS.read },
84
+ ],
85
+ reason:
86
+ 'Meta limits an Instagram professional account to 50 API-published posts in a 24-hour moving period (IG User Media '
87
+ + 'Publish and Content Publishing Limit references, "currently 50"; the Content Publishing guide says 100 — the '
88
+ + 'stricter 50 is kept) and to 400 containers in a rolling 24 hours (IG User Media reference), '
89
+ + 'developers.facebook.com/docs/instagram-platform, fetched 2026-09-27. The general call allowance is a Business Use '
90
+ + 'Case formula with no scalar. The daily figures are spread over the kernel\'s one-hour window and rounded down: '
91
+ + '250 units an hour, a publish 100 (2 an hour, 48 a day), a container 16 (15 an hour, 360 a day), a rupload POST '
92
+ + '(an 8 MiB chunk) 1, a delete 8, a read 1, anything else 6; a 180-unit burst per minute (30 calls at the default '
93
+ + "weight, the fallback's own) admits one whole perform of the largest Reel (155 units).",
94
+ };
95
+
96
+ declareRateBudget(VENDOR, INSTAGRAM_RATE_BUDGET);
97
+
98
+ /**
99
+ * Price one call. `path` is the Graph path (`/v26.0/178…/media`) or, for the upload host, the absolute
100
+ * rupload URL — keyed `"<METHOD> <path>"` with the query split off (and the upload host kept in front
101
+ * of its path, so a twin's anchored `/ig-api-upload/…` and Meta's host price the same).
102
+ */
103
+ export function instagramCallWeight(method: string, path: string): number {
104
+ let raw = path;
105
+ if (/^https?:\/\//i.test(raw)) { const u = new URL(raw); raw = `${u.hostname === 'rupload.facebook.com' ? u.hostname : ''}${u.pathname}${u.search}`; }
106
+ const at = raw.indexOf('?');
107
+ const query: Record<string, string> = {};
108
+ if (at !== -1) for (const [k, v] of new URLSearchParams(raw.slice(at + 1))) query[k] = v;
109
+ const bare = (at === -1 ? raw : raw.slice(0, at)).replace(/(.)\/+$/, '$1');
110
+ return rateBudgetWeight(VENDOR, `${String(method).toUpperCase()} ${bare}`, query);
111
+ }
112
+
113
+ /** Where this vendor's ledger lives: token-keyed and cwd-independent by default. */
114
+ export function instagramBudgetPath(opts: { root?: string; token?: string } | string = {}): string {
115
+ const o = typeof opts === 'string' ? { root: opts } : opts;
116
+ return rateBudgetPath({ ...o, vendor: VENDOR });
117
+ }
118
+
119
+ export type InstagramBudgetOptions = Omit<RateBudgetOptions, 'vendor'>;
120
+
121
+ /** This vendor's budget — the kernel guard bound to this declaration. */
122
+ export class InstagramBudget extends RateBudget {
123
+ constructor(opts: InstagramBudgetOptions = {}) {
124
+ super({ ...opts, vendor: VENDOR });
125
+ }
126
+ }
127
+
128
+ export { RateBudgetError as InstagramBudgetError } from '@volter/world-core';
129
+ export type InstagramBudgetReservation = RateBudgetReservation;
130
+ export type InstagramBudgetSnapshot = RateBudgetSnapshot;