@alaahedhly22/social-sim 0.0.0-stage → 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/README.md CHANGED
@@ -1,3 +1,72 @@
1
- # Temporary Holding Version
1
+ # @alaahedhly22/social-sim
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Point an app's Meta and TikTok calls at [Social Sim](https://github.com/Hortensia-Agency/social-sim) with one env var, and drive the sim from tests. The app keeps its own Graph and TikTok code: the same paths, bodies, tokens and webhooks. Only the origin changes.
4
+
5
+ | `SOCIAL_SIM_URL` | Where the calls go |
6
+ |---|---|
7
+ | unset (production) | `https://graph.facebook.com`, `https://open.tiktokapis.com`, … byte for byte as today |
8
+ | `https://<your sim>` | the same paths under the sim: `https://<your sim>/graph.facebook.com/v22.0/me` |
9
+
10
+ Never set `SOCIAL_SIM_URL` in production.
11
+
12
+ ## Install
13
+
14
+ ```
15
+ pnpm add @alaahedhly22/social-sim
16
+ ```
17
+
18
+ In a Claude Code session inside the app, `/integrate-social-sim` does the whole integration below.
19
+
20
+ ## Send the app's calls to the sim
21
+
22
+ Pick one.
23
+
24
+ **Build URLs from the origins (recommended).** One module in the app names the hosts; everything else imports from it:
25
+
26
+ ```ts
27
+ // src/lib/platform-hosts.ts
28
+ import { platformOrigins } from "@alaahedhly22/social-sim";
29
+
30
+ export const { metaGraph, metaWww, tiktokApi, tiktokWww } = platformOrigins();
31
+ // fetch(`${metaGraph}/v22.0/me/accounts?access_token=...`)
32
+ // The login button: `${metaWww}/v22.0/dialog/oauth?client_id=...`
33
+ ```
34
+
35
+ **Or wrap fetch,** for code or an SDK that writes `https://graph.facebook.com/...` itself. Call it once at startup (for Next.js, in `instrumentation.ts`); it does nothing when `SOCIAL_SIM_URL` is unset:
36
+
37
+ ```ts
38
+ import { installSocialSim } from "@alaahedhly22/social-sim";
39
+ installSocialSim();
40
+ ```
41
+
42
+ `withSocialSim(fetch)` returns a wrapped fetch without touching the global, and `simUrl(url)` rewrites a single URL. Remember the browser: a login link the app renders must be built with `metaWww` / `tiktokWww` (or `simUrl`), since the fetch wrapper only sees server requests.
43
+
44
+ Then, in the sim's console, register the app under **World → Apps** with the app's real id and secret and its webhook callback URL. The app's other env vars stay as they are.
45
+
46
+ ## Keep it that way
47
+
48
+ A test in the app fails when a platform host is written anywhere but the host module:
49
+
50
+ ```ts
51
+ import { findHardcodedHosts } from "@alaahedhly22/social-sim/testing";
52
+
53
+ it("names Meta and TikTok hosts only in platform-hosts.ts", () => {
54
+ expect(findHardcodedHosts("src", { allow: ["src/lib/platform-hosts.ts"] })).toEqual([]);
55
+ });
56
+ ```
57
+
58
+ ## Drive the sim from tests
59
+
60
+ `SocialSim` runs the sim's director verbs, the same ones its MCP tools expose, typed by their inputs. It reads `SOCIAL_SIM_URL` and `SOCIAL_SIM_KEY` (the sim's `SIM_ACCESS_KEY`):
61
+
62
+ ```ts
63
+ import { SocialSim } from "@alaahedhly22/social-sim/testing";
64
+
65
+ const sim = new SocialSim();
66
+ const { ids } = await sim.loadScenario("keyword-rule");
67
+ await sim.verb("post_comment", { person: ids.customer_1, targetId: ids.media_1, text: "price?" });
68
+ await sim.verb("wait_for_api_call", { method: "POST", path: `/${ids.page}/messages` });
69
+ await sim.advanceClock("25h");
70
+ ```
71
+
72
+ A refused verb throws `SocialSimError` with the sim's reason. `GET /api/director` on the sim lists every verb with its description.
@@ -0,0 +1,13 @@
1
+ type Env = Record<string, string | undefined>;
2
+ /**
3
+ * A fetch that sends Meta and TikTok requests to the sim when SOCIAL_SIM_URL
4
+ * is set, for code (or an SDK) that writes the platform URLs itself. Every
5
+ * other request, and every request with SOCIAL_SIM_URL unset, goes through untouched.
6
+ */
7
+ export declare function withSocialSim(base?: typeof fetch, env?: Env): typeof fetch;
8
+ /**
9
+ * Wraps the global fetch once at startup. Does nothing when SOCIAL_SIM_URL is
10
+ * unset, so it is safe to call in every environment, production included.
11
+ */
12
+ export declare function installSocialSim(env?: Env): boolean;
13
+ export {};
package/dist/fetch.js ADDED
@@ -0,0 +1,29 @@
1
+ import { simBase, simUrl } from "./hosts.js";
2
+ /**
3
+ * A fetch that sends Meta and TikTok requests to the sim when SOCIAL_SIM_URL
4
+ * is set, for code (or an SDK) that writes the platform URLs itself. Every
5
+ * other request, and every request with SOCIAL_SIM_URL unset, goes through untouched.
6
+ */
7
+ export function withSocialSim(base = globalThis.fetch, env = process.env) {
8
+ if (!simBase(env))
9
+ return base;
10
+ return (input, init) => {
11
+ if (typeof input === "string" || input instanceof URL)
12
+ return base(simUrl(input, env), init);
13
+ const target = simUrl(input.url, env);
14
+ return base(target === input.url ? input : new Request(target, input), init);
15
+ };
16
+ }
17
+ const INSTALLED = Symbol.for("@alaahedhly22/social-sim/installed");
18
+ /**
19
+ * Wraps the global fetch once at startup. Does nothing when SOCIAL_SIM_URL is
20
+ * unset, so it is safe to call in every environment, production included.
21
+ */
22
+ export function installSocialSim(env = process.env) {
23
+ const global = globalThis;
24
+ if (global[INSTALLED] || !simBase(env))
25
+ return false;
26
+ global.fetch = withSocialSim(global.fetch.bind(globalThis), env);
27
+ global[INSTALLED] = true;
28
+ return true;
29
+ }
@@ -0,0 +1,21 @@
1
+ export declare const HOSTS: {
2
+ readonly metaGraph: "graph.facebook.com";
3
+ readonly metaWww: "www.facebook.com";
4
+ readonly metaRupload: "rupload.facebook.com";
5
+ readonly tiktokApi: "open.tiktokapis.com";
6
+ readonly tiktokUpload: "open-upload.tiktokapis.com";
7
+ readonly tiktokWww: "www.tiktok.com";
8
+ };
9
+ export type HostName = keyof typeof HOSTS;
10
+ export type PlatformOrigins = Record<HostName, string>;
11
+ type Env = Record<string, string | undefined>;
12
+ /** The sim's origin from SOCIAL_SIM_URL, or null when the app talks to the real platforms. */
13
+ export declare function simBase(env?: Env): string | null;
14
+ /** Every platform origin, pointed at the sim when SOCIAL_SIM_URL is set. */
15
+ export declare function platformOrigins(env?: Env): PlatformOrigins;
16
+ /**
17
+ * Rewrites an absolute URL on one of the platform hosts to the sim; any other
18
+ * URL, or any URL with SOCIAL_SIM_URL unset, comes back unchanged.
19
+ */
20
+ export declare function simUrl(url: string | URL, env?: Env): string;
21
+ export {};
package/dist/hosts.js ADDED
@@ -0,0 +1,49 @@
1
+ // The one place an app names Meta's and TikTok's hosts.
2
+ //
3
+ // Social Sim serves each platform host under a path named after it
4
+ // (`${SOCIAL_SIM_URL}/graph.facebook.com/v22.0/me`), so everything past the
5
+ // origin stays byte for byte the same. With SOCIAL_SIM_URL unset every origin
6
+ // is the real one, so production never changes.
7
+ export const HOSTS = {
8
+ metaGraph: "graph.facebook.com",
9
+ metaWww: "www.facebook.com",
10
+ metaRupload: "rupload.facebook.com",
11
+ tiktokApi: "open.tiktokapis.com",
12
+ tiktokUpload: "open-upload.tiktokapis.com",
13
+ tiktokWww: "www.tiktok.com",
14
+ };
15
+ /** The sim's origin from SOCIAL_SIM_URL, or null when the app talks to the real platforms. */
16
+ export function simBase(env = process.env) {
17
+ const raw = env.SOCIAL_SIM_URL?.trim();
18
+ if (!raw)
19
+ return null;
20
+ if (!/^https?:\/\/[^/]+/.test(raw))
21
+ throw new Error(`SOCIAL_SIM_URL must be an http(s) origin, got "${raw}"`);
22
+ return raw.replace(/\/+$/, "");
23
+ }
24
+ /** Every platform origin, pointed at the sim when SOCIAL_SIM_URL is set. */
25
+ export function platformOrigins(env = process.env) {
26
+ const base = simBase(env);
27
+ return Object.fromEntries(Object.entries(HOSTS).map(([name, host]) => [name, base ? `${base}/${host}` : `https://${host}`]));
28
+ }
29
+ const KNOWN = new Set(Object.values(HOSTS));
30
+ /**
31
+ * Rewrites an absolute URL on one of the platform hosts to the sim; any other
32
+ * URL, or any URL with SOCIAL_SIM_URL unset, comes back unchanged.
33
+ */
34
+ export function simUrl(url, env = process.env) {
35
+ const text = url.toString();
36
+ const base = simBase(env);
37
+ if (!base)
38
+ return text;
39
+ let parsed;
40
+ try {
41
+ parsed = new URL(text);
42
+ }
43
+ catch {
44
+ return text;
45
+ }
46
+ if (parsed.protocol !== "https:" || !KNOWN.has(parsed.hostname))
47
+ return text;
48
+ return `${base}/${parsed.hostname}${parsed.pathname}${parsed.search}${parsed.hash}`;
49
+ }
@@ -0,0 +1,2 @@
1
+ export { HOSTS, platformOrigins, simBase, simUrl, type HostName, type PlatformOrigins } from "./hosts.js";
2
+ export { installSocialSim, withSocialSim } from "./fetch.js";
package/dist/index.js ADDED
@@ -0,0 +1,2 @@
1
+ export { HOSTS, platformOrigins, simBase, simUrl } from "./hosts.js";
2
+ export { installSocialSim, withSocialSim } from "./fetch.js";
@@ -0,0 +1,41 @@
1
+ import type { VerbInputs, VerbName } from "./verbs.js";
2
+ export type { VerbInputs, VerbName } from "./verbs.js";
3
+ type Env = Record<string, string | undefined>;
4
+ export declare class SocialSimError extends Error {
5
+ readonly verb: string;
6
+ readonly status: number;
7
+ constructor(verb: string, status: number, message: string);
8
+ }
9
+ /**
10
+ * Drives a running sim from tests: the director's verbs (the same ones the MCP
11
+ * tools expose), typed by their inputs. `url` and `key` default to
12
+ * SOCIAL_SIM_URL and SOCIAL_SIM_KEY (the sim's SIM_ACCESS_KEY).
13
+ */
14
+ export declare class SocialSim {
15
+ readonly url: string;
16
+ private readonly key;
17
+ constructor(options?: {
18
+ url?: string;
19
+ key?: string;
20
+ env?: Env;
21
+ });
22
+ /** Runs one verb and returns its result; a refusal throws SocialSimError with the sim's reason. */
23
+ verb<V extends VerbName>(name: V, ...input: Record<string, never> extends VerbInputs[V] ? [VerbInputs[V]?] : [VerbInputs[V]]): Promise<unknown>;
24
+ /** Loads a scenario and returns the ids it created (page, ig, customers, posts) and its plan. */
25
+ loadScenario(name: string): Promise<{
26
+ name: string;
27
+ ids: Record<string, string>;
28
+ plan: string[];
29
+ }>;
30
+ advanceClock(by: string): Promise<unknown>;
31
+ resetClock(): Promise<unknown>;
32
+ }
33
+ /** Every line under `dir` that names a platform host, skipping the files that may (the app's host module). */
34
+ export declare function findHardcodedHosts(dir: string, options?: {
35
+ allow?: string[];
36
+ extensions?: string[];
37
+ }): {
38
+ file: string;
39
+ line: number;
40
+ host: string;
41
+ }[];
@@ -0,0 +1,79 @@
1
+ import { readdirSync, readFileSync, statSync } from "node:fs";
2
+ import path from "node:path";
3
+ import { HOSTS } from "./hosts.js";
4
+ export class SocialSimError extends Error {
5
+ verb;
6
+ status;
7
+ constructor(verb, status, message) {
8
+ super(`${verb}: ${message}`);
9
+ this.verb = verb;
10
+ this.status = status;
11
+ }
12
+ }
13
+ /**
14
+ * Drives a running sim from tests: the director's verbs (the same ones the MCP
15
+ * tools expose), typed by their inputs. `url` and `key` default to
16
+ * SOCIAL_SIM_URL and SOCIAL_SIM_KEY (the sim's SIM_ACCESS_KEY).
17
+ */
18
+ export class SocialSim {
19
+ url;
20
+ key;
21
+ constructor(options = {}) {
22
+ const env = options.env ?? process.env;
23
+ const url = options.url ?? env.SOCIAL_SIM_URL;
24
+ const key = options.key ?? env.SOCIAL_SIM_KEY;
25
+ if (!url)
26
+ throw new Error("SocialSim needs a url, or SOCIAL_SIM_URL set.");
27
+ if (!key)
28
+ throw new Error("SocialSim needs the sim's access key, or SOCIAL_SIM_KEY set.");
29
+ this.url = url.replace(/\/+$/, "");
30
+ this.key = key;
31
+ }
32
+ /** Runs one verb and returns its result; a refusal throws SocialSimError with the sim's reason. */
33
+ async verb(name, ...input) {
34
+ const res = await fetch(`${this.url}/api/director/${name}`, {
35
+ method: "POST",
36
+ headers: { authorization: `Bearer ${this.key}`, "content-type": "application/json" },
37
+ body: JSON.stringify(input[0] ?? {}),
38
+ });
39
+ const body = (await res.json().catch(() => ({})));
40
+ if (!res.ok)
41
+ throw new SocialSimError(name, res.status, body.error ?? res.statusText);
42
+ return body.result;
43
+ }
44
+ /** Loads a scenario and returns the ids it created (page, ig, customers, posts) and its plan. */
45
+ async loadScenario(name) {
46
+ return (await this.verb("load_scenario", { name }));
47
+ }
48
+ advanceClock(by) {
49
+ return this.verb("advance_clock", { by });
50
+ }
51
+ resetClock() {
52
+ return this.verb("reset_clock");
53
+ }
54
+ }
55
+ /** Every line under `dir` that names a platform host, skipping the files that may (the app's host module). */
56
+ export function findHardcodedHosts(dir, options = {}) {
57
+ const allow = new Set((options.allow ?? []).map((file) => path.resolve(file)));
58
+ const extensions = options.extensions ?? [".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs"];
59
+ const hosts = Object.values(HOSTS);
60
+ const found = [];
61
+ const walk = (current) => {
62
+ for (const name of readdirSync(current)) {
63
+ if (name === "node_modules" || name.startsWith("."))
64
+ continue;
65
+ const full = path.join(current, name);
66
+ if (statSync(full).isDirectory())
67
+ walk(full);
68
+ else if (extensions.includes(path.extname(name)) && !allow.has(path.resolve(full)) && !/\.(test|spec)\.\w+$/.test(name)) {
69
+ readFileSync(full, "utf8").split("\n").forEach((text, index) => {
70
+ for (const host of hosts)
71
+ if (text.includes(host))
72
+ found.push({ file: full, line: index + 1, host });
73
+ });
74
+ }
75
+ }
76
+ };
77
+ walk(dir);
78
+ return found;
79
+ }
@@ -0,0 +1,547 @@
1
+ export interface VerbInputs {
2
+ /** Start here: the clock, apps, people, Pages, creators, counts, delivery states, active faults and unmapped calls. */
3
+ describe_world: Record<string, never>;
4
+ /** The scenario packs, each a known starting world for a Sparkia feature, with the features it covers. */
5
+ list_scenarios: Record<string, never>;
6
+ /** Resets the world (apps and their settings stay) and builds a scenario. Returns the ids of everything it created and the test plan with those ids filled in. Sparkia has to connect again afterwards: its old tokens and ids are gone. */
7
+ load_scenario: {
8
+ name: string;
9
+ };
10
+ /** Empties the world and resets the clock, limits and Graph versions. Apps stay unless keepApps is false. */
11
+ reset_world: {
12
+ keepApps?: boolean;
13
+ };
14
+ /** Saves the whole world, apps and clock included, under a name (replacing a snapshot of that name). */
15
+ snapshot_world: {
16
+ name: string;
17
+ };
18
+ /** Replaces the world with a snapshot, clock included. Tokens Sparkia holds work again if they were in the snapshot. */
19
+ restore_snapshot: {
20
+ name: string;
21
+ };
22
+ /** Saved snapshots, newest first. */
23
+ list_snapshots: Record<string, never>;
24
+ /** Deletes a saved snapshot. */
25
+ delete_snapshot: {
26
+ name: string;
27
+ };
28
+ /** The sim time every window, expiry, schedule and retry reads, its offset from wall time, and whether it is frozen. */
29
+ get_clock: Record<string, never>;
30
+ /** Moves sim time forward (or back with a minus sign). Windows close, tokens and containers expire, and due webhooks and scheduled posts fire on the next tick, about a second later. */
31
+ advance_clock: {
32
+ /** A duration such as "25h", "90m", "7d", "1d12h", or milliseconds. */
33
+ by: string | number;
34
+ };
35
+ /** Sets sim time to an ISO 8601 instant. A frozen clock stays frozen at the new instant. */
36
+ set_clock: {
37
+ at: string;
38
+ };
39
+ /** Stops sim time where it is; nothing becomes due until it is advanced or resumed. */
40
+ freeze_clock: Record<string, never>;
41
+ /** Lets a frozen clock run again from the instant it shows. */
42
+ resume_clock: Record<string, never>;
43
+ /** Puts sim time back on wall time, running. */
44
+ reset_clock: Record<string, never>;
45
+ /** The Meta and TikTok apps in the sim with their mode, webhook setup and subscribed fields (secrets redacted). */
46
+ list_apps: Record<string, never>;
47
+ /** Registers an app. Give the app id (or client_key) and secret from Sparkia's env so Sparkia's calls authenticate; omit them to mint new ones. */
48
+ create_app: {
49
+ platform: "META" | "TIKTOK";
50
+ name: string;
51
+ appId?: string;
52
+ secret?: string;
53
+ };
54
+ /** Changes App Dashboard settings: mode (DEV or LIVE), app type (BUSINESS or CONSUMER), access level, audit status (TikTok), webhook callback (verified with the hub challenge before it is saved, like Meta does), subscribed fields as "object.field" (e.g. "instagram.comments", "page.feed"), and the other switches. */
55
+ configure_app: {
56
+ /** The app's sim id, Meta app id or TikTok client_key. */
57
+ app: string;
58
+ name?: string;
59
+ mode?: "DEV" | "LIVE";
60
+ /** Meta only. BUSINESS (the default): long-lived user tokens never expire. CONSUMER: they last 60 days. */
61
+ appType?: "BUSINESS" | "CONSUMER";
62
+ accessLevel?: "STANDARD" | "ADVANCED";
63
+ audited?: boolean;
64
+ requireAppSecretProof?: boolean;
65
+ includeValues?: boolean;
66
+ verifiedDomains?: string[];
67
+ subscribedFields?: string[];
68
+ callbackUrl?: string;
69
+ verifyToken?: string;
70
+ deauthorizeUrl?: string | null;
71
+ dataDeletionUrl?: string | null;
72
+ };
73
+ /** Runs the hub challenge against the app's saved callback URL, as the App Dashboard's Verify button does. */
74
+ verify_webhook: {
75
+ /** The app's sim id, Meta app id or TikTok client_key. */
76
+ app: string;
77
+ };
78
+ /** Every permission (TikTok: scope) the app's calls have needed since its history began, each with the endpoints that needed it, how many calls lacked it (Meta would refuse those), and the permissions its webhook subscriptions need. `reviewList` is what to request in App Review. Kept across scenario loads and resets. */
79
+ get_app_permissions: {
80
+ /** The app's sim id, Meta app id or TikTok client_key. */
81
+ app: string;
82
+ };
83
+ /** Forgets the app's permission history, so the next calls start a fresh list. */
84
+ clear_app_permissions: {
85
+ /** The app's sim id, Meta app id or TikTok client_key. */
86
+ app: string;
87
+ };
88
+ /** Queues the App Dashboard's sample event for a field, e.g. "instagram.comments". */
89
+ send_test_webhook: {
90
+ /** The app's sim id, Meta app id or TikTok client_key. */
91
+ app: string;
92
+ field: string;
93
+ };
94
+ /** Gives a person a role on an app (ADMIN, DEVELOPER, TESTER), which a Development-mode app requires before they can log in; null removes it. */
95
+ set_app_role: {
96
+ /** The app's sim id, Meta app id or TikTok client_key. */
97
+ app: string;
98
+ /** A person: the sim id, the Facebook user id, a PSID or IGSID, the Instagram username, or the exact name. */
99
+ person: string;
100
+ role: "ADMIN" | "DEVELOPER" | "TESTER" | null;
101
+ };
102
+ /** Sets a person's tasks on a Page (default: full admin, ADVERTISE, ANALYZE, CREATE_CONTENT, MESSAGING, MODERATE, MANAGE). Null removes the role, and their tokens for the Page answer 190/492 from then on. */
103
+ set_page_role: {
104
+ /** A person: the sim id, the Facebook user id, a PSID or IGSID, the Instagram username, or the exact name. */
105
+ person: string;
106
+ pageId: string;
107
+ tasks?: string[] | null;
108
+ };
109
+ /** Tokens the sim minted (Meta user, page and app tokens, TikTok access tokens) with their state; values are redacted. */
110
+ list_tokens: {
111
+ /** The app's sim id, Meta app id or TikTok client_key. */
112
+ app?: string;
113
+ /** A person: the sim id, the Facebook user id, a PSID or IGSID, the Instagram username, or the exact name. */
114
+ person?: string;
115
+ };
116
+ /** Makes a token expire now: a Meta token answers 190 (expired session); a TikTok access token answers access_token_invalid while its refresh token still works unless refresh is true. */
117
+ expire_token: {
118
+ id: string;
119
+ /** TikTok: expire the refresh token too. */
120
+ refresh?: boolean;
121
+ };
122
+ /** Makes a Meta token answer 190 with a subcode from now on: 458 app removed, 460 password changed, 463 expired, 467 invalid session, 492 page role lost. */
123
+ invalidate_token: {
124
+ id: string;
125
+ subcode: 458 | 460 | 463 | 467 | 492;
126
+ };
127
+ /** The person removes the app in their Facebook settings: their tokens answer 190/458 and the app's deauthorize callback is called. With deleteData, the data deletion callback is called too. */
128
+ remove_app_from_person: {
129
+ /** The app's sim id, Meta app id or TikTok client_key. */
130
+ app: string;
131
+ /** A person: the sim id, the Facebook user id, a PSID or IGSID, the Instagram username, or the exact name. */
132
+ person: string;
133
+ deleteData?: boolean;
134
+ };
135
+ /** Adds a person (a customer, or a Page admin who logs in through the dialog). Returns their sim id and Facebook user id. */
136
+ create_person: {
137
+ name: string;
138
+ email?: string;
139
+ phone?: string;
140
+ /** Needed to comment or DM on Instagram. */
141
+ igUsername?: string;
142
+ /** Facebook leaves `from` out of this person's comments. */
143
+ privateProfile?: boolean;
144
+ twoFactor?: boolean;
145
+ igFollowerCount?: number;
146
+ igVerified?: boolean;
147
+ };
148
+ /** Everyone in the world with their ids, Instagram handle, scoped ids and the Pages they manage. */
149
+ list_people: Record<string, never>;
150
+ /** Removes a person with their tokens, comments, likes and threads. */
151
+ delete_person: {
152
+ /** A person: the sim id, the Facebook user id, a PSID or IGSID, the Instagram username, or the exact name. */
153
+ person: string;
154
+ };
155
+ /** Adds a Facebook Page, optionally with a linked Instagram professional account, and makes a person its admin. */
156
+ create_page: {
157
+ name: string;
158
+ username?: string;
159
+ category?: string;
160
+ fanCount?: number;
161
+ followersCount?: number;
162
+ /** A person: the sim id, the Facebook user id, a PSID or IGSID, the Instagram username, or the exact name. */
163
+ admin?: string;
164
+ instagram?: {
165
+ username: string;
166
+ name?: string;
167
+ biography?: string;
168
+ followersCount?: number;
169
+ followsCount?: number;
170
+ /** API publishes per rolling 24 h; 100 by default. */
171
+ publishQuota?: number;
172
+ imageProcessingSec?: number;
173
+ videoProcessingSec?: number;
174
+ };
175
+ };
176
+ /** Pages with their linked Instagram accounts, admins and the apps subscribed to them. */
177
+ list_pages: Record<string, never>;
178
+ /** Removes a Page and its linked Instagram account with all their posts, media, comments and threads. Tokens for the Page stop working. */
179
+ delete_page: {
180
+ pageId: string;
181
+ };
182
+ /** A person follows or unfollows an Instagram account; Sparkia's follow gate reads this through is_user_follow_business. */
183
+ set_follow: {
184
+ /** A person: the sim id, the Facebook user id, a PSID or IGSID, the Instagram username, or the exact name. */
185
+ person: string;
186
+ /** The Instagram account id or username. */
187
+ instagram: string;
188
+ follows: boolean;
189
+ };
190
+ /** Whether Facebook shows who wrote this person's comments; hidden leaves `from` out of Graph reads, as Meta does for some profiles. */
191
+ set_from_visibility: {
192
+ /** A person: the sim id, the Facebook user id, a PSID or IGSID, the Instagram username, or the exact name. */
193
+ person: string;
194
+ visible: boolean;
195
+ };
196
+ /** The owner posts from the phone app (not through the API): a Facebook post (text, photo or video; emits the Page's feed webhook) or Instagram media (post, reel or story; no webhook, no quota). Gives customers something to comment on. */
197
+ create_post: {
198
+ /** A business: a Page id or username, or an Instagram account id or username. */
199
+ business: string;
200
+ kind?: "post" | "reel" | "story";
201
+ /** The caption or message. */
202
+ text?: string;
203
+ /** A bundled sample (square.jpg, portrait.jpg, wide.jpg, square.png, short.mp4, landscape.mp4, story.mp4) or a URL the sim fetches. */
204
+ media?: {
205
+ fixture: "square.jpg" | "portrait.jpg" | "wide.jpg" | "square.png" | "short.mp4" | "landscape.mp4" | "story.mp4";
206
+ } | {
207
+ url: string;
208
+ };
209
+ };
210
+ /** The owner deletes a Facebook post or Instagram media in the app; Graph reads of it fail from then on, as after a delete on the platform. */
211
+ delete_post: {
212
+ id: string;
213
+ };
214
+ /** A business's posts or media, newest first, including what Sparkia published, with comment counts and whether each is live. */
215
+ list_posts: {
216
+ /** A business: a Page id or username, or an Instagram account id or username. */
217
+ business: string;
218
+ limit?: number;
219
+ };
220
+ /** Turns comments on or off for an Instagram media, as the owner can in the app; comments on it are then refused. */
221
+ set_comments_enabled: {
222
+ mediaId: string;
223
+ enabled: boolean;
224
+ };
225
+ /** The owner hides, unhides or deletes a comment from the app. Emits what Meta sends for it (feed verb hide or remove on Facebook); deleting removes the replies too. */
226
+ moderate_comment: {
227
+ commentId: string;
228
+ action: "hide" | "unhide" | "delete";
229
+ };
230
+ /** A person comments on a Facebook post or Instagram media, or replies when targetId is a comment (a reply to a reply lands under the top comment, as both apps thread it). Emits the comments or feed webhook. */
231
+ post_comment: {
232
+ /** A person: the sim id, the Facebook user id, a PSID or IGSID, the Instagram username, or the exact name. */
233
+ person: string;
234
+ /** A Facebook post id, Instagram media id, or a comment id to reply to. */
235
+ targetId: string;
236
+ text: string;
237
+ /** Facebook only: a photo attached to the comment. */
238
+ photo?: {
239
+ fixture: "square.jpg" | "portrait.jpg" | "wide.jpg" | "square.png" | "short.mp4" | "landscape.mp4" | "story.mp4";
240
+ } | {
241
+ url: string;
242
+ };
243
+ /** Instagram only: the comment was left on an ad promoting this media. */
244
+ ad?: {
245
+ id: string;
246
+ title: string;
247
+ };
248
+ };
249
+ /** A person deletes a comment they wrote (and its replies go with it). */
250
+ delete_comment: {
251
+ /** A person: the sim id, the Facebook user id, a PSID or IGSID, the Instagram username, or the exact name. */
252
+ person: string;
253
+ commentId: string;
254
+ };
255
+ /** A person likes a Facebook post (emits a feed reaction webhook), an Instagram media or a comment. Liking twice changes nothing. */
256
+ like: {
257
+ /** A person: the sim id, the Facebook user id, a PSID or IGSID, the Instagram username, or the exact name. */
258
+ person: string;
259
+ targetId: string;
260
+ };
261
+ /** A person sends a direct message to a Page (Messenger) or an Instagram account. Opens or extends the 24 h window and emits the messages webhook. */
262
+ send_dm: {
263
+ /** A person: the sim id, the Facebook user id, a PSID or IGSID, the Instagram username, or the exact name. */
264
+ person: string;
265
+ /** A business: a Page id or username, or an Instagram account id or username. */
266
+ business: string;
267
+ text?: string;
268
+ attachments?: ({
269
+ type: "image" | "video" | "audio" | "file";
270
+ url: string;
271
+ })[];
272
+ replyToMid?: string;
273
+ };
274
+ /** A person taps a quick reply or a postback button on a business message (the latest one by default), chosen by title, payload or position. A quick reply sends a message with its payload; a postback button sends messaging_postbacks; link and call buttons leave the thread and send nothing. */
275
+ tap_button: {
276
+ /** A person: the sim id, the Facebook user id, a PSID or IGSID, the Instagram username, or the exact name. */
277
+ person: string;
278
+ /** A business: a Page id or username, or an Instagram account id or username. */
279
+ business: string;
280
+ /** The business message; defaults to the latest one. */
281
+ mid?: string;
282
+ title?: string;
283
+ payload?: string;
284
+ index?: number;
285
+ /** Generic template: which card the button is on. */
286
+ element?: number;
287
+ };
288
+ /** A person taps one of the ice breakers the business set with messenger_profile, by position (0 is the first). */
289
+ tap_ice_breaker: {
290
+ /** A person: the sim id, the Facebook user id, a PSID or IGSID, the Instagram username, or the exact name. */
291
+ person: string;
292
+ /** A business: a Page id or username, or an Instagram account id or username. */
293
+ business: string;
294
+ index?: number;
295
+ };
296
+ /** A person taps an item in the business's persistent menu, by position (0 is the first). A postback item sends messaging_postbacks; a link item opens a page and sends nothing. */
297
+ tap_menu: {
298
+ /** A person: the sim id, the Facebook user id, a PSID or IGSID, the Instagram username, or the exact name. */
299
+ person: string;
300
+ /** A business: a Page id or username, or an Instagram account id or username. */
301
+ business: string;
302
+ index?: number;
303
+ };
304
+ /** A person taps Get Started on a new Messenger thread with a Page. */
305
+ tap_get_started: {
306
+ /** A person: the sim id, the Facebook user id, a PSID or IGSID, the Instagram username, or the exact name. */
307
+ person: string;
308
+ pageId: string;
309
+ };
310
+ /** A person reacts to a message (the business's latest by default), or takes the reaction back with null. */
311
+ react_to_message: {
312
+ /** A person: the sim id, the Facebook user id, a PSID or IGSID, the Instagram username, or the exact name. */
313
+ person: string;
314
+ /** A business: a Page id or username, or an Instagram account id or username. */
315
+ business: string;
316
+ mid?: string;
317
+ reaction?: "love" | "smile" | "wow" | "sad" | "angry" | "like" | null;
318
+ };
319
+ /** A person opens the thread and sees the business's latest message (emits the read or seen webhook; the window does not move). */
320
+ mark_seen: {
321
+ /** A person: the sim id, the Facebook user id, a PSID or IGSID, the Instagram username, or the exact name. */
322
+ person: string;
323
+ /** A business: a Page id or username, or an Instagram account id or username. */
324
+ business: string;
325
+ };
326
+ /** A person edits one of their own messages. */
327
+ edit_message: {
328
+ /** A person: the sim id, the Facebook user id, a PSID or IGSID, the Instagram username, or the exact name. */
329
+ person: string;
330
+ mid: string;
331
+ text: string;
332
+ };
333
+ /** A person opens the thread from an m.me or ig.me link carrying a ref (emits the referral webhook). */
334
+ open_referral: {
335
+ /** A person: the sim id, the Facebook user id, a PSID or IGSID, the Instagram username, or the exact name. */
336
+ person: string;
337
+ /** A business: a Page id or username, or an Instagram account id or username. */
338
+ business: string;
339
+ ref: string;
340
+ };
341
+ /** Calls Sparkia made to the sim's platform APIs, newest first, with request, response and status. */
342
+ list_api_calls: {
343
+ /** e.g. "graph.facebook.com" or "open.tiktokapis.com". */
344
+ host?: string;
345
+ method?: string;
346
+ /** A part of the request path, e.g. "/messages" or "/media_publish". */
347
+ path?: string;
348
+ status?: number;
349
+ /** Only calls logged after this API call id. */
350
+ afterId?: string;
351
+ limit?: number;
352
+ };
353
+ /** One API call with its full request and response. */
354
+ get_api_call: {
355
+ id: string;
356
+ };
357
+ /** Blocks until Sparkia makes a matching call logged after afterId (or after this tool was called) and returns it; returns {timedOut: true} otherwise. Use it to assert that Sparkia reacted, e.g. path "/messages" after posting a comment. Call it after the action, not alongside it (tool calls may run one at a time), and pass the action's apiCallCursor as afterId so a reply made before the wait began still counts. */
358
+ wait_for_api_call: {
359
+ /** e.g. "graph.facebook.com" or "open.tiktokapis.com". */
360
+ host?: string;
361
+ method?: string;
362
+ /** A part of the request path, e.g. "/messages" or "/media_publish". */
363
+ path?: string;
364
+ status?: number;
365
+ /** Match calls after this id (a customer action's apiCallCursor) instead of after now. */
366
+ afterId?: string;
367
+ timeoutMs?: number;
368
+ };
369
+ /** Webhook deliveries the sim sent or is sending to apps, newest first, with body, status and last attempt. */
370
+ list_webhook_deliveries: {
371
+ /** The sim id of the app. */
372
+ appId?: string;
373
+ object?: "page" | "instagram" | "tiktok";
374
+ status?: "PENDING" | "SENDING" | "RETRY_WAIT" | "DELIVERED" | "DEAD";
375
+ /** Text the delivered body contains, e.g. a comment id or "messaging_postbacks". */
376
+ contains?: string;
377
+ limit?: number;
378
+ };
379
+ /** Blocks until a matching delivery is created after this tool was called and returns it; returns {timedOut: true} otherwise. Add status DELIVERED to wait until the app accepted it. */
380
+ wait_for_webhook_delivery: {
381
+ /** The sim id of the app. */
382
+ appId?: string;
383
+ object?: "page" | "instagram" | "tiktok";
384
+ status?: "PENDING" | "SENDING" | "RETRY_WAIT" | "DELIVERED" | "DEAD";
385
+ /** Text the delivered body contains, e.g. a comment id or "messaging_postbacks". */
386
+ contains?: string;
387
+ timeoutMs?: number;
388
+ };
389
+ /** Sends a delivery's exact body again as a new delivery, the way the App Dashboard's resend does. */
390
+ redeliver_webhook: {
391
+ deliveryId: string;
392
+ };
393
+ /** Comments on a Facebook post or Instagram media with their replies, including hidden and deleted ones, as the business sees them. */
394
+ list_comments: {
395
+ /** A Facebook post id or Instagram media id. */
396
+ targetId: string;
397
+ };
398
+ /** The DM thread between a person and a business as the customer sees it, with what Sparkia sent (templates, quick replies, attachments) and whether the 24 h window is open. */
399
+ get_conversation: {
400
+ /** A person: the sim id, the Facebook user id, a PSID or IGSID, the Instagram username, or the exact name. */
401
+ person: string;
402
+ /** A business: a Page id or username, or an Instagram account id or username. */
403
+ business: string;
404
+ };
405
+ /** Changes a rate-limit ceiling. Kinds: app and user (Graph app and user buckets), pages, instagram and messenger (Business Use Case buckets), tiktok_post and tiktok_status. Lower one to trip throttling in a few calls. */
406
+ set_limit: {
407
+ kind: "app" | "user" | "pages" | "instagram" | "messenger" | "tiktok_post" | "tiktok_status";
408
+ ceiling?: number;
409
+ windowSec?: number;
410
+ timeCeilingMs?: number;
411
+ };
412
+ /** Every bucket's calls in its current window against its ceiling, as the usage headers report them. */
413
+ get_usage: Record<string, never>;
414
+ /** Sets a bucket's current window to a percentage of its ceiling without spending calls, e.g. 95 to make Sparkia back off. Buckets: "app:{metaAppId}", "user:{metaAppId}:{personSimId}", "buc:{pageId}:{pages|instagram|messenger}". */
415
+ set_usage: {
416
+ bucket: string;
417
+ percent: number;
418
+ };
419
+ /** Makes matching platform requests fail instead of reaching the handler: a platform error body (GRAPH_ERROR, also TikTok's envelope on TikTok hosts), a bare STATUS, Meta's HTML outage page, a DELAY, or a DROPped connection. First matching rule wins. */
420
+ add_fault: {
421
+ outcome: "GRAPH_ERROR" | "STATUS" | "HTML" | "DELAY" | "DROP";
422
+ /** Defaults to "graph.facebook.com". */
423
+ host?: string;
424
+ method?: string;
425
+ /** A regex on the path after the version, e.g. "oauth/access_token" or "messages$". */
426
+ pathPattern?: string;
427
+ /** Only requests made with this token (an id from list_tokens). */
428
+ tokenId?: string;
429
+ /** Matches before the rule is spent; omit for unlimited. */
430
+ count?: number;
431
+ /** Sim time the rule lives for; omit to keep it until cleared. */
432
+ ttl?: string | number;
433
+ status?: number;
434
+ error?: {
435
+ code: number;
436
+ subcode?: number;
437
+ type?: string;
438
+ message: string;
439
+ };
440
+ delayMs?: number;
441
+ };
442
+ /** Active fault rules in match order, with hits and matches left. */
443
+ list_faults: Record<string, never>;
444
+ /** Removes every fault rule. */
445
+ clear_faults: Record<string, never>;
446
+ /** How the sim delivers one app's webhooks: batch window, retry schedule ("meta" is Meta's real one, "compressed" retries within an hour), timeout, duplicate and reorder chances (0 to 1), and an added delay. */
447
+ set_webhook_policy: {
448
+ /** The app's sim id, Meta app id or TikTok client_key. */
449
+ app: string;
450
+ batchMs?: number;
451
+ schedule?: "meta" | "compressed" | number[];
452
+ timeoutMs?: number;
453
+ duplicateRate?: number;
454
+ reorderRate?: number;
455
+ delayMs?: number;
456
+ };
457
+ /** Sunsets a Graph API version at a sim instant (calls on it are served on the oldest live version from then, as Meta upgrades them), or makes it live again with null. */
458
+ set_version_sunset: {
459
+ version: string;
460
+ at: string | null;
461
+ };
462
+ /** Sets insight metric values (reach, likes, views, ...) on an Instagram media; metrics not named keep their value. */
463
+ set_media_insights: {
464
+ mediaId: string;
465
+ values: Record<string, number>;
466
+ };
467
+ /** Adds a TikTok creator someone can log in as on the Login Kit page. Private by default, since unaudited apps may only post to private accounts. */
468
+ tiktok_create_creator: {
469
+ displayName: string;
470
+ bio?: string;
471
+ /** Unaudited apps may only post to private accounts. */
472
+ isPrivate?: boolean;
473
+ isVerified?: boolean;
474
+ followerCount?: number;
475
+ followingCount?: number;
476
+ likesCount?: number;
477
+ commentDisabled?: boolean;
478
+ duetDisabled?: boolean;
479
+ stitchDisabled?: boolean;
480
+ maxVideoPostDurationSec?: number;
481
+ bannedFromPosting?: boolean;
482
+ dailyPostCap?: number;
483
+ /** Sim seconds a publish stays processing. */
484
+ processingSec?: number;
485
+ /** Sim seconds a public post waits before it is publicly available. */
486
+ moderationSec?: number;
487
+ username: string;
488
+ };
489
+ /** Changes a creator's account: privacy, posting ban and daily cap, duration limit, interaction switches, counts, and the processing and moderation delays. */
490
+ tiktok_set_creator: {
491
+ displayName?: string;
492
+ bio?: string;
493
+ /** Unaudited apps may only post to private accounts. */
494
+ isPrivate?: boolean;
495
+ isVerified?: boolean;
496
+ followerCount?: number;
497
+ followingCount?: number;
498
+ likesCount?: number;
499
+ commentDisabled?: boolean;
500
+ duetDisabled?: boolean;
501
+ stitchDisabled?: boolean;
502
+ maxVideoPostDurationSec?: number;
503
+ bannedFromPosting?: boolean;
504
+ dailyPostCap?: number;
505
+ /** Sim seconds a publish stays processing. */
506
+ processingSec?: number;
507
+ /** Sim seconds a public post waits before it is publicly available. */
508
+ moderationSec?: number;
509
+ /** A TikTok creator: the sim id, the username, or an open_id an app holds. */
510
+ creator: string;
511
+ };
512
+ /** Creators with the apps they authorized (open_id, scopes, removed or not) and their video counts. */
513
+ tiktok_list_creators: Record<string, never>;
514
+ /** The creator posts a video from the TikTok app (not through the API). A public one shows in video/list at once. */
515
+ tiktok_create_video: {
516
+ /** A TikTok creator: the sim id, the username, or an open_id an app holds. */
517
+ creator: string;
518
+ title?: string;
519
+ fixture?: "square.jpg" | "portrait.jpg" | "wide.jpg" | "square.png" | "short.mp4" | "landscape.mp4" | "story.mp4";
520
+ privacyLevel?: "PUBLIC_TO_EVERYONE" | "MUTUAL_FOLLOW_FRIENDS" | "FOLLOWER_OF_CREATOR" | "SELF_ONLY";
521
+ };
522
+ /** Content Posting API publishes with the status status/fetch would report now, the fail reason and the post id. */
523
+ tiktok_list_publishes: {
524
+ /** A TikTok creator: the sim id, the username, or an open_id an app holds. */
525
+ creator?: string;
526
+ };
527
+ /** Decides how a publish ends once its media is in (status no longer PROCESSING_UPLOAD with no bytes): fail it with a documented fail_reason, and/or change how long processing and moderation take from now. The post.publish webhooks follow. */
528
+ tiktok_set_publish_outcome: {
529
+ publishId: string;
530
+ /** Omit to let the publish complete. */
531
+ failReason?: "file_format_check_failed" | "duration_check_failed" | "frame_rate_check_failed" | "picture_size_check_failed" | "internal" | "video_pull_failed" | "photo_pull_failed" | "publish_cancelled" | "auth_removed" | "spam_risk_too_many_posts" | "spam_risk_user_banned_from_posting" | "spam_risk_text" | "spam_risk";
532
+ /** Processing ends this long from now. */
533
+ processing?: string | number;
534
+ /** A public post becomes publicly available this long after processing ends. */
535
+ moderation?: string | number;
536
+ };
537
+ /** Ends a creator's authorization of an app: tokens stop working, in-flight publishes fail with auth_removed, and the app is sent authorization.removed with the reason (0 unknown, 1 userDisconnect, 2 accountDeleted, 3 ageChanged, 4 accountBanned, 5 developerRevoke). */
538
+ tiktok_remove_authorization: {
539
+ openId?: string;
540
+ /** A TikTok creator: the sim id, the username, or an open_id an app holds. */
541
+ creator?: string;
542
+ /** With creator: the app's sim id or client_key. */
543
+ app?: string;
544
+ reason?: number;
545
+ };
546
+ }
547
+ export type VerbName = keyof VerbInputs;
package/dist/verbs.js ADDED
@@ -0,0 +1,2 @@
1
+ // Generated from the sim's director verbs by `pnpm client:verbs`; do not edit.
2
+ export {};
package/package.json CHANGED
@@ -1,6 +1,44 @@
1
1
  {
2
2
  "name": "@alaahedhly22/social-sim",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "description": "Point an app's Meta and TikTok calls at Social Sim with one env var, and drive the sim from tests.",
5
+ "license": "UNLICENSED",
6
+ "type": "module",
7
+ "exports": {
8
+ ".": {
9
+ "types": "./dist/index.d.ts",
10
+ "default": "./dist/index.js"
11
+ },
12
+ "./testing": {
13
+ "types": "./dist/testing.d.ts",
14
+ "default": "./dist/testing.js"
15
+ }
16
+ },
17
+ "files": [
18
+ "dist",
19
+ "README.md"
20
+ ],
21
+ "engines": {
22
+ "node": ">=18"
23
+ },
24
+ "scripts": {
25
+ "build": "tsc -p tsconfig.json"
26
+ },
27
+ "repository": {
28
+ "type": "git",
29
+ "url": "git+ssh://git@github.com/Hortensia-Agency/social-sim.git",
30
+ "directory": "packages/client"
31
+ },
32
+ "publishConfig": {
33
+ "access": "public"
34
+ },
35
+ "keywords": [
36
+ "meta",
37
+ "facebook",
38
+ "instagram",
39
+ "tiktok",
40
+ "graph-api",
41
+ "testing",
42
+ "simulator"
43
+ ]
44
+ }