@opencoredev/social-sdk 0.1.2 → 0.2.1
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 +6 -0
- package/dist/cli.d.ts +13 -0
- package/dist/cli.js +234 -0
- package/dist/cloud/common.d.ts +25 -0
- package/dist/cloud/common.js +334 -0
- package/dist/cloud/lifecycle.d.ts +10 -0
- package/dist/cloud/lifecycle.js +112 -0
- package/dist/cloud/media.d.ts +23 -0
- package/dist/cloud/media.js +100 -0
- package/dist/cloud/outcomes.d.ts +9 -0
- package/dist/cloud/outcomes.js +195 -0
- package/dist/cloud/post-for-me.d.ts +69 -0
- package/dist/cloud/post-for-me.js +396 -0
- package/dist/cloud/zernio.d.ts +111 -0
- package/dist/cloud/zernio.js +632 -0
- package/dist/core/adapter.d.ts +158 -0
- package/dist/core/adapter.js +3 -0
- package/dist/core/client.d.ts +141 -0
- package/dist/core/client.js +1285 -0
- package/dist/core/concurrency.d.ts +9 -0
- package/dist/core/concurrency.js +79 -0
- package/dist/core/errors.d.ts +44 -0
- package/dist/core/errors.js +50 -0
- package/dist/core/idempotency.d.ts +33 -0
- package/dist/core/idempotency.js +58 -0
- package/dist/core/index.d.ts +6 -0
- package/dist/core/index.js +6 -0
- package/dist/core/pagination.d.ts +11 -0
- package/dist/core/pagination.js +81 -0
- package/dist/core/types.d.ts +396 -0
- package/dist/core/types.js +16 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/platforms/bluesky.d.ts +261 -0
- package/dist/platforms/bluesky.js +1776 -0
- package/dist/platforms/instagram.d.ts +129 -0
- package/dist/platforms/instagram.js +1031 -0
- package/dist/platforms/linkedin.d.ts +108 -0
- package/dist/platforms/linkedin.js +890 -0
- package/dist/platforms/threads.d.ts +134 -0
- package/dist/platforms/threads.js +945 -0
- package/dist/platforms/tiktok.d.ts +31 -0
- package/dist/platforms/tiktok.js +593 -0
- package/dist/platforms/x-engagement.d.ts +16 -0
- package/dist/platforms/x-engagement.js +50 -0
- package/dist/platforms/x-text.d.ts +2 -0
- package/dist/platforms/x-text.js +123 -0
- package/dist/platforms/x-tlds.d.ts +1 -0
- package/dist/platforms/x-tlds.js +2 -0
- package/dist/platforms/x.d.ts +299 -0
- package/dist/platforms/x.js +1287 -0
- package/dist/platforms/youtube-upload.d.ts +32 -0
- package/dist/platforms/youtube-upload.js +296 -0
- package/dist/platforms/youtube.d.ts +108 -0
- package/dist/platforms/youtube.js +1100 -0
- package/dist/server/connections.d.ts +123 -0
- package/dist/server/connections.js +335 -0
- package/dist/server/credentials.d.ts +51 -0
- package/dist/server/credentials.js +108 -0
- package/dist/server/index.d.ts +4 -0
- package/dist/server/index.js +4 -0
- package/dist/server/oauth.d.ts +52 -0
- package/dist/server/oauth.js +553 -0
- package/dist/server/webhooks.d.ts +57 -0
- package/dist/server/webhooks.js +204 -0
- package/dist/testing/index.d.ts +46 -0
- package/dist/testing/index.js +564 -0
- package/dist/testing.d.ts +1 -0
- package/dist/testing.js +1 -0
- package/dist/transport/binary.d.ts +2 -0
- package/dist/transport/binary.js +29 -0
- package/dist/transport/budget.d.ts +3 -0
- package/dist/transport/budget.js +26 -0
- package/dist/transport/http.d.ts +44 -0
- package/dist/transport/http.js +259 -0
- package/dist/transport/json.d.ts +8 -0
- package/dist/transport/json.js +16 -0
- package/dist/transport/upload.d.ts +25 -0
- package/dist/transport/upload.js +153 -0
- package/dist/transport/validation.d.ts +5 -0
- package/dist/transport/validation.js +24 -0
- package/package.json +2 -7
|
@@ -0,0 +1,1031 @@
|
|
|
1
|
+
/* oxlint-disable anti-slop/no-conditional-empty-object-spread, anti-slop/no-known-value-widening, anti-slop/no-runtime-typeof, anti-slop/no-unsafe-dictionary-type, anti-slop/require-readable-spacing -- provider query and response boundaries are validated locally. */
|
|
2
|
+
import { defineAdapter } from "../core/adapter.js";
|
|
3
|
+
import { SocialError } from "../core/errors.js";
|
|
4
|
+
import { profileRef } from "../core/types.js";
|
|
5
|
+
import { managedHttp, publicFields } from "../cloud/common.js";
|
|
6
|
+
import { HttpError } from "../transport/http.js";
|
|
7
|
+
import { array, object, optionalNumber, optionalString, string } from "../transport/validation.js";
|
|
8
|
+
import { httpsUrl } from "../transport/upload.js";
|
|
9
|
+
class MemoryInstagramWorkflowStore {
|
|
10
|
+
workflows = new Map();
|
|
11
|
+
claims = new Set();
|
|
12
|
+
async create(input) {
|
|
13
|
+
const workflow = { ...input, id: `igwf_${globalThis.crypto.randomUUID()}` };
|
|
14
|
+
this.workflows.set(workflow.id, workflow);
|
|
15
|
+
return workflow;
|
|
16
|
+
}
|
|
17
|
+
async get(id) {
|
|
18
|
+
return this.workflows.get(id);
|
|
19
|
+
}
|
|
20
|
+
async update(id, update) {
|
|
21
|
+
const current = this.workflows.get(id);
|
|
22
|
+
if (!current)
|
|
23
|
+
throw new Error("Instagram workflow not found");
|
|
24
|
+
const next = { ...current, ...update, id };
|
|
25
|
+
this.workflows.set(id, next);
|
|
26
|
+
return next;
|
|
27
|
+
}
|
|
28
|
+
async claim(id) {
|
|
29
|
+
if (!this.workflows.has(id) || this.claims.has(id))
|
|
30
|
+
return false;
|
|
31
|
+
this.claims.add(id);
|
|
32
|
+
return true;
|
|
33
|
+
}
|
|
34
|
+
async release(id) {
|
|
35
|
+
this.claims.delete(id);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
export function instagram(options) {
|
|
39
|
+
const apiVersion = "v25.0";
|
|
40
|
+
const flavor = options.auth.flavor ?? "instagram-login";
|
|
41
|
+
const origin = flavor === "facebook-login"
|
|
42
|
+
? `https://graph.facebook.com/${apiVersion}`
|
|
43
|
+
: `https://graph.instagram.com/${apiVersion}`;
|
|
44
|
+
const request = managedHttp(origin, {
|
|
45
|
+
apiKey: options.auth.accessToken,
|
|
46
|
+
// oxlint-disable-next-line anti-slop/no-conditional-empty-object-spread -- validated boundary or fixture contract.
|
|
47
|
+
...(options.fetch ? { fetch: options.fetch } : {}),
|
|
48
|
+
});
|
|
49
|
+
const now = () => (options.clock?.() ?? new Date()).toISOString();
|
|
50
|
+
const workflows = options.workflowStore ?? new MemoryInstagramWorkflowStore();
|
|
51
|
+
// oxlint-disable-next-line anti-slop/no-unknown-parameters -- provider payload is validated here.
|
|
52
|
+
const validatedObject = (value, operation) => {
|
|
53
|
+
try {
|
|
54
|
+
return object(value);
|
|
55
|
+
}
|
|
56
|
+
catch (error) {
|
|
57
|
+
if (error instanceof HttpError && error.kind === "invalid-response")
|
|
58
|
+
throw new SocialError({
|
|
59
|
+
code: "upstream_failure",
|
|
60
|
+
operation,
|
|
61
|
+
message: error.message,
|
|
62
|
+
cause: error,
|
|
63
|
+
});
|
|
64
|
+
throw error;
|
|
65
|
+
}
|
|
66
|
+
};
|
|
67
|
+
const requireFacebookLogin = (operation) => {
|
|
68
|
+
if (flavor !== "facebook-login")
|
|
69
|
+
throw new SocialError({
|
|
70
|
+
code: "unsupported_capability",
|
|
71
|
+
operation,
|
|
72
|
+
message: `${operation} requires a Facebook Login Graph API access token for an Instagram professional account.`,
|
|
73
|
+
retryDisposition: { kind: "never" },
|
|
74
|
+
});
|
|
75
|
+
};
|
|
76
|
+
const pageLimit = (limit, max = 50) => {
|
|
77
|
+
const value = limit ?? 25;
|
|
78
|
+
if (!Number.isSafeInteger(value) || value < 1 || value > max)
|
|
79
|
+
throw new SocialError({
|
|
80
|
+
code: "invalid_input",
|
|
81
|
+
operation: "instagram.pagination",
|
|
82
|
+
message: `Instagram page limit must be an integer from 1 through ${max}.`,
|
|
83
|
+
});
|
|
84
|
+
return value;
|
|
85
|
+
};
|
|
86
|
+
const page = (result, fields) => {
|
|
87
|
+
const items = array(result["data"]).map((entry) => publicFields(entry, fields));
|
|
88
|
+
const paging = result["paging"] === undefined ? {} : object(result["paging"]);
|
|
89
|
+
const cursors = paging["cursors"] === undefined ? {} : object(paging["cursors"]);
|
|
90
|
+
// Graph API omits `paging.next` on the last page even when `cursors.after` is present.
|
|
91
|
+
const nextCursor = typeof paging["next"] === "string" &&
|
|
92
|
+
typeof cursors["after"] === "string" &&
|
|
93
|
+
cursors["after"].length > 0
|
|
94
|
+
? cursors["after"]
|
|
95
|
+
: undefined;
|
|
96
|
+
return { items, ...(nextCursor === undefined ? {} : { nextCursor }) };
|
|
97
|
+
};
|
|
98
|
+
const authorize = (ref, context) => {
|
|
99
|
+
if (ref.backend !== context.backendInstance ||
|
|
100
|
+
ref.platform !== "instagram" ||
|
|
101
|
+
ref.accountId !== options.auth.accountId)
|
|
102
|
+
throw new SocialError({
|
|
103
|
+
code: "unauthorized",
|
|
104
|
+
operation: "instagram",
|
|
105
|
+
message: "Account reference does not match this Instagram authorization.",
|
|
106
|
+
});
|
|
107
|
+
};
|
|
108
|
+
const status = async (id, context) => object(await request(`/${encodeURIComponent(id)}`, context, undefined, {
|
|
109
|
+
fields: "id,status_code,status",
|
|
110
|
+
}));
|
|
111
|
+
const listPosts = async (selected, input, context) => {
|
|
112
|
+
authorize(selected, context);
|
|
113
|
+
if (input.limit !== undefined &&
|
|
114
|
+
(!Number.isSafeInteger(input.limit) || input.limit < 1 || input.limit > 100))
|
|
115
|
+
throw new SocialError({
|
|
116
|
+
code: "invalid_input",
|
|
117
|
+
operation: "posts.list",
|
|
118
|
+
message: "Instagram feed limit must be an integer from 1 through 100.",
|
|
119
|
+
});
|
|
120
|
+
// oxlint-disable-next-line anti-slop/no-known-value-widening -- validated boundary or fixture contract.
|
|
121
|
+
const query = {
|
|
122
|
+
fields: "id,caption,media_type,media_product_type,permalink,timestamp,username",
|
|
123
|
+
};
|
|
124
|
+
if (input.cursor !== undefined)
|
|
125
|
+
query["after"] = input.cursor;
|
|
126
|
+
if (input.limit !== undefined)
|
|
127
|
+
query["limit"] = String(input.limit);
|
|
128
|
+
const result = object(await request(`/${encodeURIComponent(selected.accountId)}/media`, context, undefined, query));
|
|
129
|
+
const items = array(result["data"]).map((entry) => publicFields(entry, [
|
|
130
|
+
"id",
|
|
131
|
+
"caption",
|
|
132
|
+
"media_type",
|
|
133
|
+
"media_product_type",
|
|
134
|
+
"permalink",
|
|
135
|
+
"timestamp",
|
|
136
|
+
"username",
|
|
137
|
+
]));
|
|
138
|
+
const paging = result["paging"] === undefined ? {} : object(result["paging"]);
|
|
139
|
+
const cursors = paging["cursors"] === undefined ? {} : object(paging["cursors"]);
|
|
140
|
+
// oxlint-disable-next-line anti-slop/no-runtime-typeof -- validated boundary or fixture contract.
|
|
141
|
+
const hasNext = typeof paging["next"] === "string" && paging["next"].length > 0;
|
|
142
|
+
const nextCursor =
|
|
143
|
+
// oxlint-disable-next-line anti-slop/no-runtime-typeof -- validated boundary or fixture contract.
|
|
144
|
+
hasNext && typeof cursors["after"] === "string" ? cursors["after"] : undefined;
|
|
145
|
+
return {
|
|
146
|
+
items,
|
|
147
|
+
// oxlint-disable-next-line anti-slop/no-conditional-empty-object-spread -- validated boundary or fixture contract.
|
|
148
|
+
...(nextCursor === undefined ? {} : { nextCursor }),
|
|
149
|
+
};
|
|
150
|
+
};
|
|
151
|
+
const getAccountMetrics = async (selected, context) => {
|
|
152
|
+
authorize(selected, context);
|
|
153
|
+
const result = object(await request(flavor === "facebook-login" ? `/${encodeURIComponent(selected.accountId)}` : "/me", context, undefined, {
|
|
154
|
+
fields: flavor === "facebook-login"
|
|
155
|
+
? "id,followers_count,media_count"
|
|
156
|
+
: "user_id,followers_count,media_count",
|
|
157
|
+
}));
|
|
158
|
+
const id = optionalString(result["user_id"]) ?? optionalString(result["id"]);
|
|
159
|
+
if (id !== selected.accountId)
|
|
160
|
+
throw new SocialError({
|
|
161
|
+
code: "unauthorized",
|
|
162
|
+
operation: "analytics.read",
|
|
163
|
+
message: "Instagram returned metrics for a different account.",
|
|
164
|
+
});
|
|
165
|
+
return ["followers_count", "media_count"].flatMap((name) => {
|
|
166
|
+
const value = optionalNumber(result[name]);
|
|
167
|
+
return value === undefined
|
|
168
|
+
? []
|
|
169
|
+
: [
|
|
170
|
+
{
|
|
171
|
+
name,
|
|
172
|
+
value,
|
|
173
|
+
unit: "count",
|
|
174
|
+
period: "lifetime",
|
|
175
|
+
fetchedAt: now(),
|
|
176
|
+
freshness: "unknown",
|
|
177
|
+
source: `${flavor === "facebook-login" ? "Facebook Login" : "Instagram Login"} Graph v25.0`,
|
|
178
|
+
},
|
|
179
|
+
];
|
|
180
|
+
});
|
|
181
|
+
};
|
|
182
|
+
const publishContainer = async (account, containerId, context, workflowId) => {
|
|
183
|
+
authorize(account, context);
|
|
184
|
+
if (workflowId) {
|
|
185
|
+
const workflow = await workflows.get(workflowId);
|
|
186
|
+
if (workflow?.nativeId)
|
|
187
|
+
return {
|
|
188
|
+
...processing(account, workflowId, "PUBLISHED"),
|
|
189
|
+
state: "published",
|
|
190
|
+
post: {
|
|
191
|
+
kind: "platform-post",
|
|
192
|
+
version: 1,
|
|
193
|
+
backend: account.backend,
|
|
194
|
+
platform: "instagram",
|
|
195
|
+
accountId: account.accountId,
|
|
196
|
+
postId: workflow.nativeId,
|
|
197
|
+
},
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
const state = await status(containerId, context);
|
|
201
|
+
const code = string(state["status_code"]);
|
|
202
|
+
const base = {
|
|
203
|
+
account,
|
|
204
|
+
targetIndex: 0,
|
|
205
|
+
observedAt: now(),
|
|
206
|
+
backendState: code,
|
|
207
|
+
delivery: {
|
|
208
|
+
kind: "delivery",
|
|
209
|
+
version: 1,
|
|
210
|
+
backend: account.backend,
|
|
211
|
+
platform: "instagram",
|
|
212
|
+
accountId: account.accountId,
|
|
213
|
+
deliveryId: workflowId ?? containerId,
|
|
214
|
+
},
|
|
215
|
+
};
|
|
216
|
+
if (code === "IN_PROGRESS")
|
|
217
|
+
return { ...base, state: "processing" };
|
|
218
|
+
if (code === "ERROR" || code === "EXPIRED")
|
|
219
|
+
return {
|
|
220
|
+
...base,
|
|
221
|
+
state: "failed",
|
|
222
|
+
code: "media_error",
|
|
223
|
+
message: "Instagram container failed or expired before publication.",
|
|
224
|
+
retryDisposition: { kind: "never" },
|
|
225
|
+
};
|
|
226
|
+
if (code === "PUBLISHED")
|
|
227
|
+
return {
|
|
228
|
+
...base,
|
|
229
|
+
state: "unknown",
|
|
230
|
+
reason: "unmapped-state",
|
|
231
|
+
diagnostic: "Instagram reports a published container but the native media ID was not persisted by this workflow.",
|
|
232
|
+
};
|
|
233
|
+
if (code !== "FINISHED")
|
|
234
|
+
return {
|
|
235
|
+
...base,
|
|
236
|
+
state: "unknown",
|
|
237
|
+
reason: "unmapped-state",
|
|
238
|
+
diagnostic: "Container is already published or has an unmapped state. No repeated publish was dispatched.",
|
|
239
|
+
};
|
|
240
|
+
if (workflowId)
|
|
241
|
+
await workflows.update(workflowId, { stage: "unknown" });
|
|
242
|
+
let result;
|
|
243
|
+
try {
|
|
244
|
+
// oxlint-disable-next-line anti-slop/require-safety-comment-for-type-assertion -- validated boundary or fixture contract.
|
|
245
|
+
result = object(await request(`/${encodeURIComponent(account.accountId)}/media_publish`, context, {
|
|
246
|
+
creation_id: containerId,
|
|
247
|
+
}));
|
|
248
|
+
}
|
|
249
|
+
catch (error) {
|
|
250
|
+
if (workflowId)
|
|
251
|
+
await workflows.update(workflowId, { stage: "unknown" });
|
|
252
|
+
throw error;
|
|
253
|
+
}
|
|
254
|
+
const nativeId = string(result["id"]);
|
|
255
|
+
if (workflowId)
|
|
256
|
+
await workflows.update(workflowId, { nativeId, stage: "published", parentId: containerId });
|
|
257
|
+
return {
|
|
258
|
+
...base,
|
|
259
|
+
state: "published",
|
|
260
|
+
backendState: "PUBLISHED",
|
|
261
|
+
post: {
|
|
262
|
+
kind: "platform-post",
|
|
263
|
+
version: 1,
|
|
264
|
+
backend: account.backend,
|
|
265
|
+
platform: "instagram",
|
|
266
|
+
accountId: account.accountId,
|
|
267
|
+
postId: nativeId,
|
|
268
|
+
},
|
|
269
|
+
};
|
|
270
|
+
};
|
|
271
|
+
const processing = (account, workflowId, backendState) => ({
|
|
272
|
+
state: "processing",
|
|
273
|
+
targetIndex: 0,
|
|
274
|
+
account,
|
|
275
|
+
observedAt: now(),
|
|
276
|
+
backendState,
|
|
277
|
+
delivery: {
|
|
278
|
+
kind: "delivery",
|
|
279
|
+
version: 1,
|
|
280
|
+
backend: account.backend,
|
|
281
|
+
platform: "instagram",
|
|
282
|
+
accountId: account.accountId,
|
|
283
|
+
deliveryId: workflowId,
|
|
284
|
+
},
|
|
285
|
+
});
|
|
286
|
+
const continueWorkflow = async (workflow, account, context) => {
|
|
287
|
+
if (workflow.nativeId)
|
|
288
|
+
return {
|
|
289
|
+
...processing(account, workflow.id, "PUBLISHED"),
|
|
290
|
+
state: "published",
|
|
291
|
+
post: {
|
|
292
|
+
kind: "platform-post",
|
|
293
|
+
version: 1,
|
|
294
|
+
backend: account.backend,
|
|
295
|
+
platform: "instagram",
|
|
296
|
+
accountId: account.accountId,
|
|
297
|
+
postId: workflow.nativeId,
|
|
298
|
+
},
|
|
299
|
+
};
|
|
300
|
+
if (workflow.stage === "unknown")
|
|
301
|
+
return {
|
|
302
|
+
...processing(account, workflow.id, "AMBIGUOUS"),
|
|
303
|
+
state: "unknown",
|
|
304
|
+
reason: "ambiguous-submission",
|
|
305
|
+
diagnostic: "Instagram publish acceptance is unknown; no replay was attempted.",
|
|
306
|
+
};
|
|
307
|
+
if (workflow.parentId)
|
|
308
|
+
return publishContainer(account, workflow.parentId, context, workflow.id);
|
|
309
|
+
for (const child of workflow.childIds) {
|
|
310
|
+
const childStatus = await status(child, context);
|
|
311
|
+
const code = string(childStatus["status_code"]);
|
|
312
|
+
if (code !== "FINISHED")
|
|
313
|
+
return processing(account, workflow.id, code);
|
|
314
|
+
}
|
|
315
|
+
await workflows.update(workflow.id, { stage: "unknown" });
|
|
316
|
+
const parent = object(await request(`/${encodeURIComponent(account.accountId)}/media`, context, {
|
|
317
|
+
media_type: "CAROUSEL",
|
|
318
|
+
children: workflow.childIds.join(","),
|
|
319
|
+
caption: workflow.caption,
|
|
320
|
+
}));
|
|
321
|
+
const parentId = string(parent["id"]);
|
|
322
|
+
await workflows.update(workflow.id, { parentId, stage: "parent" });
|
|
323
|
+
return publishContainer(account, parentId, context, workflow.id);
|
|
324
|
+
};
|
|
325
|
+
const resumeWorkflow = async (workflow, account, context) => {
|
|
326
|
+
if (!(await workflows.claim(workflow.id)))
|
|
327
|
+
return processing(account, workflow.id, "CLAIMED");
|
|
328
|
+
try {
|
|
329
|
+
const current = (await workflows.get(workflow.id)) ?? workflow;
|
|
330
|
+
return await continueWorkflow(current, account, context);
|
|
331
|
+
}
|
|
332
|
+
finally {
|
|
333
|
+
await workflows.release?.(workflow.id);
|
|
334
|
+
}
|
|
335
|
+
};
|
|
336
|
+
const readAccount = async (context) => {
|
|
337
|
+
const response = object(await request(flavor === "facebook-login" ? `/${encodeURIComponent(options.auth.accountId)}` : "/me", context, undefined, {
|
|
338
|
+
fields: flavor === "facebook-login"
|
|
339
|
+
? "id,username,account_type"
|
|
340
|
+
: "user_id,username,account_type",
|
|
341
|
+
}));
|
|
342
|
+
const id = optionalString(response["user_id"]) ?? optionalString(response["id"]);
|
|
343
|
+
if (id !== options.auth.accountId)
|
|
344
|
+
throw new SocialError({
|
|
345
|
+
code: "unauthorized",
|
|
346
|
+
operation: "accounts.read",
|
|
347
|
+
message: "Instagram returned an account different from the configured professional account.",
|
|
348
|
+
});
|
|
349
|
+
return {
|
|
350
|
+
ref: {
|
|
351
|
+
kind: "connected-account",
|
|
352
|
+
version: 1,
|
|
353
|
+
backend: context.backendInstance,
|
|
354
|
+
platform: "instagram",
|
|
355
|
+
accountId: id,
|
|
356
|
+
},
|
|
357
|
+
displayName: string(response["username"]),
|
|
358
|
+
status: "connected",
|
|
359
|
+
};
|
|
360
|
+
};
|
|
361
|
+
const listMentions = async ({ account, cursor, limit, context, }) => {
|
|
362
|
+
authorize(account, context);
|
|
363
|
+
requireFacebookLogin("instagram.mentions.read");
|
|
364
|
+
const query = {
|
|
365
|
+
fields: "id,caption,media_type,media_product_type,permalink,timestamp,username",
|
|
366
|
+
limit: String(pageLimit(limit)),
|
|
367
|
+
};
|
|
368
|
+
if (cursor !== undefined)
|
|
369
|
+
query["after"] = cursor;
|
|
370
|
+
const result = validatedObject(await request(`/${encodeURIComponent(account.accountId)}/tags`, context, undefined, query), "instagram.mentions.read");
|
|
371
|
+
return page(result, [
|
|
372
|
+
"id",
|
|
373
|
+
"caption",
|
|
374
|
+
"media_type",
|
|
375
|
+
"media_product_type",
|
|
376
|
+
"permalink",
|
|
377
|
+
"timestamp",
|
|
378
|
+
"username",
|
|
379
|
+
]);
|
|
380
|
+
};
|
|
381
|
+
const profileFields = [
|
|
382
|
+
"id",
|
|
383
|
+
"username",
|
|
384
|
+
"name",
|
|
385
|
+
"biography",
|
|
386
|
+
"profile_picture_url",
|
|
387
|
+
"followers_count",
|
|
388
|
+
"follows_count",
|
|
389
|
+
"media_count",
|
|
390
|
+
"website",
|
|
391
|
+
];
|
|
392
|
+
return defineAdapter({
|
|
393
|
+
id: "instagram",
|
|
394
|
+
capabilities: {
|
|
395
|
+
schemaVersion: 1,
|
|
396
|
+
backend: "instagram",
|
|
397
|
+
apiRevision: `${flavor === "facebook-login" ? "Facebook Login" : "Instagram Login"} Graph v25.0`,
|
|
398
|
+
runtime: ["node22", "node24", "bun"],
|
|
399
|
+
capabilities: [
|
|
400
|
+
{
|
|
401
|
+
platform: "instagram",
|
|
402
|
+
operation: "posts.publish",
|
|
403
|
+
availability: "available",
|
|
404
|
+
formats: ["image", "video", "carousel"],
|
|
405
|
+
requiredScopes: flavor === "facebook-login"
|
|
406
|
+
? ["instagram_basic", "instagram_content_publish", "pages_read_engagement"]
|
|
407
|
+
: ["instagram_business_basic", "instagram_business_content_publish"],
|
|
408
|
+
notes: "Professional accounts, public HTTPS media, explicit native continuation for processing containers. Carousels must have matching aspect ratios to avoid upstream cropping.",
|
|
409
|
+
},
|
|
410
|
+
...[
|
|
411
|
+
"accounts.read",
|
|
412
|
+
"posts.list",
|
|
413
|
+
"posts.read",
|
|
414
|
+
"posts.status",
|
|
415
|
+
"comments.read",
|
|
416
|
+
"comments.write",
|
|
417
|
+
"analytics.read",
|
|
418
|
+
"analytics.account.read",
|
|
419
|
+
].map((operation) => ({
|
|
420
|
+
operation,
|
|
421
|
+
platform: "instagram",
|
|
422
|
+
availability: "available",
|
|
423
|
+
})),
|
|
424
|
+
{
|
|
425
|
+
platform: "instagram",
|
|
426
|
+
operation: "comments.moderate",
|
|
427
|
+
availability: "available",
|
|
428
|
+
requiredScopes: flavor === "facebook-login"
|
|
429
|
+
? ["instagram_manage_comments", "pages_read_engagement"]
|
|
430
|
+
: ["instagram_business_manage_comments"],
|
|
431
|
+
notes: "Hide or unhide a comment, or enable or disable comments on media. Instagram Login and Facebook Login are supported where the account and app permissions qualify.",
|
|
432
|
+
},
|
|
433
|
+
{
|
|
434
|
+
platform: "instagram",
|
|
435
|
+
operation: "comments.delete",
|
|
436
|
+
availability: "available",
|
|
437
|
+
requiredScopes: flavor === "facebook-login"
|
|
438
|
+
? ["instagram_manage_comments", "pages_read_engagement"]
|
|
439
|
+
: ["instagram_business_manage_comments"],
|
|
440
|
+
},
|
|
441
|
+
{
|
|
442
|
+
platform: "instagram",
|
|
443
|
+
operation: "comments.replies.read",
|
|
444
|
+
availability: "available",
|
|
445
|
+
requiredScopes: flavor === "facebook-login"
|
|
446
|
+
? ["instagram_manage_comments", "pages_read_engagement"]
|
|
447
|
+
: ["instagram_business_manage_comments"],
|
|
448
|
+
},
|
|
449
|
+
{
|
|
450
|
+
platform: "instagram",
|
|
451
|
+
operation: "profiles.read",
|
|
452
|
+
availability: "available",
|
|
453
|
+
requiredScopes: flavor === "facebook-login"
|
|
454
|
+
? ["instagram_basic", "instagram_manage_insights", "pages_read_engagement"]
|
|
455
|
+
: ["instagram_business_basic", "instagram_business_manage_insights"],
|
|
456
|
+
},
|
|
457
|
+
{
|
|
458
|
+
platform: "instagram",
|
|
459
|
+
operation: "reels.publish",
|
|
460
|
+
availability: "available",
|
|
461
|
+
formats: ["video"],
|
|
462
|
+
},
|
|
463
|
+
{ platform: "instagram", operation: "stories.publish", availability: "available" },
|
|
464
|
+
{
|
|
465
|
+
platform: "instagram",
|
|
466
|
+
operation: "posts.delete",
|
|
467
|
+
availability: flavor === "facebook-login"
|
|
468
|
+
? "available"
|
|
469
|
+
: "not-implemented-by-adapter",
|
|
470
|
+
...(flavor === "facebook-login"
|
|
471
|
+
? { requiredScopes: ["instagram_basic", "pages_read_engagement"] }
|
|
472
|
+
: {}),
|
|
473
|
+
},
|
|
474
|
+
{
|
|
475
|
+
platform: "instagram",
|
|
476
|
+
operation: "posts.removeFromPlatform",
|
|
477
|
+
availability: flavor === "facebook-login"
|
|
478
|
+
? "available"
|
|
479
|
+
: "not-implemented-by-adapter",
|
|
480
|
+
},
|
|
481
|
+
...(flavor === "facebook-login"
|
|
482
|
+
? [
|
|
483
|
+
{
|
|
484
|
+
platform: "instagram",
|
|
485
|
+
operation: "hashtags.search",
|
|
486
|
+
availability: "available",
|
|
487
|
+
requiredScopes: ["instagram_basic"],
|
|
488
|
+
notes: "Requires Facebook Login, Instagram Public Content Access feature approval, and a qualifying Instagram professional account.",
|
|
489
|
+
},
|
|
490
|
+
]
|
|
491
|
+
: []),
|
|
492
|
+
{
|
|
493
|
+
platform: "instagram",
|
|
494
|
+
operation: "publishing.limit.read",
|
|
495
|
+
availability: "available",
|
|
496
|
+
},
|
|
497
|
+
...(flavor === "facebook-login"
|
|
498
|
+
? [
|
|
499
|
+
{
|
|
500
|
+
platform: "instagram",
|
|
501
|
+
operation: "mentions.read",
|
|
502
|
+
availability: "available",
|
|
503
|
+
requiredScopes: [
|
|
504
|
+
"instagram_basic",
|
|
505
|
+
"instagram_manage_comments",
|
|
506
|
+
"pages_read_engagement",
|
|
507
|
+
],
|
|
508
|
+
notes: "Reads the paginated /{ig-user-id}/tags edge.",
|
|
509
|
+
},
|
|
510
|
+
]
|
|
511
|
+
: [
|
|
512
|
+
{
|
|
513
|
+
platform: "instagram",
|
|
514
|
+
operation: "mentions.read",
|
|
515
|
+
availability: "not-implemented-by-adapter",
|
|
516
|
+
},
|
|
517
|
+
]),
|
|
518
|
+
{
|
|
519
|
+
platform: "instagram",
|
|
520
|
+
operation: "product.tagging",
|
|
521
|
+
availability: "approval-dependent",
|
|
522
|
+
},
|
|
523
|
+
{
|
|
524
|
+
platform: "instagram",
|
|
525
|
+
operation: "messages.read",
|
|
526
|
+
availability: "approval-dependent",
|
|
527
|
+
},
|
|
528
|
+
],
|
|
529
|
+
},
|
|
530
|
+
accounts: {
|
|
531
|
+
async list(_input, context) {
|
|
532
|
+
return { items: [await readAccount(context)] };
|
|
533
|
+
},
|
|
534
|
+
async get(ref, context) {
|
|
535
|
+
authorize(ref, context);
|
|
536
|
+
return readAccount(context);
|
|
537
|
+
},
|
|
538
|
+
},
|
|
539
|
+
posts: {
|
|
540
|
+
list: listPosts,
|
|
541
|
+
prepareTarget(target) {
|
|
542
|
+
const issues = [];
|
|
543
|
+
const fail = (code, message) => issues.push({ code, message, severity: "error", targetIndex: target.targetIndex });
|
|
544
|
+
if (target.account.platform !== "instagram" ||
|
|
545
|
+
target.account.accountId !== options.auth.accountId)
|
|
546
|
+
fail("instagram.account", "Select the configured professional Instagram account.");
|
|
547
|
+
const media = target.content.media ?? [];
|
|
548
|
+
if (media.length < 1 || media.length > 10)
|
|
549
|
+
fail("instagram.media", "Instagram requires 1 to 10 image/video attachments.");
|
|
550
|
+
const firstRatio = media[0]?.width && media[0]?.height ? media[0].width / media[0].height : undefined;
|
|
551
|
+
for (const item of media) {
|
|
552
|
+
if (item.source.kind !== "https-url")
|
|
553
|
+
fail("instagram.source", "Instagram Login publishing requires public HTTPS media URLs.");
|
|
554
|
+
else
|
|
555
|
+
try {
|
|
556
|
+
httpsUrl(item.source.url);
|
|
557
|
+
}
|
|
558
|
+
catch {
|
|
559
|
+
fail("instagram.url", "Use public HTTPS media without local hosts or credentials.");
|
|
560
|
+
}
|
|
561
|
+
if (item.kind === "image" && item.mimeType !== "image/jpeg")
|
|
562
|
+
fail("instagram.jpeg", "Instagram image publishing requires JPEG media.");
|
|
563
|
+
if (item.kind === "video" &&
|
|
564
|
+
item.mimeType !== "video/mp4" &&
|
|
565
|
+
item.mimeType !== "video/quicktime")
|
|
566
|
+
fail("instagram.video", "Provide MP4 or MOV video with a supported codec.");
|
|
567
|
+
if (media.length > 1 &&
|
|
568
|
+
(!item.width ||
|
|
569
|
+
!item.height ||
|
|
570
|
+
firstRatio === undefined ||
|
|
571
|
+
Math.abs(item.width / item.height - firstRatio) > 0.001))
|
|
572
|
+
fail("instagram.carousel_crop", "Provide matching known aspect ratios for every carousel item; the SDK will not silently accept cropping.");
|
|
573
|
+
}
|
|
574
|
+
if ((target.content.text?.length ?? 0) > 2200)
|
|
575
|
+
fail("instagram.caption", "Caption exceeds 2,200 characters.");
|
|
576
|
+
if (target.schedule || target.replyTo || target.content.link)
|
|
577
|
+
fail("instagram.operation", "Use an explicit job runner, comment reply, or caption link instead of unsupported structured options.");
|
|
578
|
+
return issues;
|
|
579
|
+
},
|
|
580
|
+
async publishTarget(target, context) {
|
|
581
|
+
authorize(target.account, context);
|
|
582
|
+
const media = target.content.media ?? [];
|
|
583
|
+
const children = [];
|
|
584
|
+
const workflow = await workflows.create({
|
|
585
|
+
backend: target.account.backend,
|
|
586
|
+
accountId: target.account.accountId,
|
|
587
|
+
childIds: [],
|
|
588
|
+
caption: target.content.text ?? "",
|
|
589
|
+
stage: "children",
|
|
590
|
+
});
|
|
591
|
+
try {
|
|
592
|
+
for (const item of media) {
|
|
593
|
+
if (item.source.kind !== "https-url")
|
|
594
|
+
throw new SocialError({
|
|
595
|
+
code: "invalid_input",
|
|
596
|
+
operation: "posts.publish",
|
|
597
|
+
message: "Public HTTPS media required.",
|
|
598
|
+
});
|
|
599
|
+
const config = target.options === undefined ? {} : object(target.options);
|
|
600
|
+
await workflows.update(workflow.id, { stage: "unknown" });
|
|
601
|
+
const created = object(await request(`/${encodeURIComponent(target.account.accountId)}/media`, context, {
|
|
602
|
+
...(item.kind === "image"
|
|
603
|
+
? {
|
|
604
|
+
image_url: item.source.url,
|
|
605
|
+
// oxlint-disable-next-line anti-slop/no-conditional-empty-object-spread -- validated boundary or fixture contract.
|
|
606
|
+
...(item.altText === undefined ? {} : { alt_text: item.altText }),
|
|
607
|
+
}
|
|
608
|
+
: {
|
|
609
|
+
video_url: item.source.url,
|
|
610
|
+
media_type: media.length > 1 ? "VIDEO" : "REELS",
|
|
611
|
+
// oxlint-disable-next-line anti-slop/no-conditional-empty-object-spread -- validated boundary or fixture contract.
|
|
612
|
+
// oxlint-disable-next-line anti-slop/no-runtime-typeof -- validated boundary or fixture contract.
|
|
613
|
+
// oxlint-disable-next-line anti-slop/no-conditional-empty-object-spread -- provider payload is validated at this adapter boundary.
|
|
614
|
+
// oxlint-disable-next-line anti-slop/no-runtime-typeof -- validated external boundary or fixture contract.
|
|
615
|
+
// oxlint-disable-next-line anti-slop/no-conditional-empty-object-spread -- validated external boundary or fixture contract.
|
|
616
|
+
// oxlint-disable-next-line anti-slop/no-runtime-typeof -- validated external boundary or fixture contract.
|
|
617
|
+
// oxlint-disable-next-line anti-slop/no-conditional-empty-object-spread -- validated external boundary or fixture contract.
|
|
618
|
+
// oxlint-disable-next-line anti-slop/no-runtime-typeof -- validated external boundary or fixture contract.
|
|
619
|
+
...(typeof config["shareToFeed"] === "boolean"
|
|
620
|
+
? { share_to_feed: config["shareToFeed"] }
|
|
621
|
+
: {}),
|
|
622
|
+
}),
|
|
623
|
+
...(media.length > 1
|
|
624
|
+
? { is_carousel_item: true }
|
|
625
|
+
: { caption: target.content.text ?? "" }),
|
|
626
|
+
}));
|
|
627
|
+
children.push(string(created["id"]));
|
|
628
|
+
await workflows.update(workflow.id, { childIds: [...children], stage: "children" });
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
catch (error) {
|
|
632
|
+
if (error instanceof SocialError)
|
|
633
|
+
throw new SocialError({
|
|
634
|
+
code: "ambiguous_outcome",
|
|
635
|
+
operation: "instagram.posts.publish",
|
|
636
|
+
message: error.message,
|
|
637
|
+
details: {
|
|
638
|
+
createdContainerIds: children,
|
|
639
|
+
workflowId: workflow.id,
|
|
640
|
+
},
|
|
641
|
+
retryDisposition: { kind: "reconcile-first" },
|
|
642
|
+
cause: error,
|
|
643
|
+
});
|
|
644
|
+
throw error;
|
|
645
|
+
}
|
|
646
|
+
let containerId = children[0];
|
|
647
|
+
if (!containerId)
|
|
648
|
+
throw new Error("Missing Instagram container");
|
|
649
|
+
if (children.length > 1) {
|
|
650
|
+
const result = await resumeWorkflow(workflow, target.account, context);
|
|
651
|
+
return { ...result, targetIndex: target.targetIndex };
|
|
652
|
+
}
|
|
653
|
+
await workflows.update(workflow.id, { parentId: containerId, stage: "parent" });
|
|
654
|
+
const result = await publishContainer(target.account, containerId, context, workflow.id);
|
|
655
|
+
return { ...result, targetIndex: target.targetIndex };
|
|
656
|
+
},
|
|
657
|
+
async get(ref, context) {
|
|
658
|
+
authorize(ref, context);
|
|
659
|
+
return publicFields(await request(`/${encodeURIComponent(ref.postId)}`, context, undefined, {
|
|
660
|
+
fields: "id,caption,media_type,media_product_type,permalink,timestamp,username",
|
|
661
|
+
}), [
|
|
662
|
+
"id",
|
|
663
|
+
"caption",
|
|
664
|
+
"media_type",
|
|
665
|
+
"media_product_type",
|
|
666
|
+
"permalink",
|
|
667
|
+
"timestamp",
|
|
668
|
+
"username",
|
|
669
|
+
]);
|
|
670
|
+
},
|
|
671
|
+
async getDelivery(ref, context) {
|
|
672
|
+
authorize(ref, context);
|
|
673
|
+
const workflow = await workflows.get(ref.deliveryId);
|
|
674
|
+
if (workflow) {
|
|
675
|
+
if (workflow.backend !== ref.backend || workflow.accountId !== ref.accountId)
|
|
676
|
+
throw new SocialError({
|
|
677
|
+
code: "unauthorized",
|
|
678
|
+
operation: "instagram.posts.status",
|
|
679
|
+
message: "Instagram workflow handle is not authorized for this account.",
|
|
680
|
+
});
|
|
681
|
+
const account = {
|
|
682
|
+
kind: "connected-account",
|
|
683
|
+
version: 1,
|
|
684
|
+
backend: ref.backend,
|
|
685
|
+
platform: "instagram",
|
|
686
|
+
accountId: ref.accountId,
|
|
687
|
+
};
|
|
688
|
+
if (workflow.nativeId)
|
|
689
|
+
return {
|
|
690
|
+
...processing(account, workflow.id, "PUBLISHED"),
|
|
691
|
+
state: "published",
|
|
692
|
+
post: {
|
|
693
|
+
kind: "platform-post",
|
|
694
|
+
version: 1,
|
|
695
|
+
backend: ref.backend,
|
|
696
|
+
platform: "instagram",
|
|
697
|
+
accountId: ref.accountId,
|
|
698
|
+
postId: workflow.nativeId,
|
|
699
|
+
},
|
|
700
|
+
};
|
|
701
|
+
return resumeWorkflow(workflow, account, context);
|
|
702
|
+
}
|
|
703
|
+
const result = await status(ref.deliveryId, context);
|
|
704
|
+
const code = string(result["status_code"]);
|
|
705
|
+
const base = {
|
|
706
|
+
account: {
|
|
707
|
+
kind: "connected-account",
|
|
708
|
+
version: 1,
|
|
709
|
+
backend: ref.backend,
|
|
710
|
+
platform: "instagram",
|
|
711
|
+
accountId: ref.accountId,
|
|
712
|
+
},
|
|
713
|
+
targetIndex: 0,
|
|
714
|
+
observedAt: now(),
|
|
715
|
+
backendState: code,
|
|
716
|
+
delivery: { kind: "delivery", version: 1, ...ref },
|
|
717
|
+
};
|
|
718
|
+
if (code === "FINISHED") {
|
|
719
|
+
const workflow = await workflows.get(ref.deliveryId);
|
|
720
|
+
if (workflow)
|
|
721
|
+
return resumeWorkflow(workflow, base.account, context);
|
|
722
|
+
}
|
|
723
|
+
if (code === "IN_PROGRESS")
|
|
724
|
+
return { ...base, state: "processing" };
|
|
725
|
+
if (code === "ERROR" || code === "EXPIRED")
|
|
726
|
+
return {
|
|
727
|
+
...base,
|
|
728
|
+
state: "failed",
|
|
729
|
+
code: "media_error",
|
|
730
|
+
message: "Instagram container failed or expired.",
|
|
731
|
+
retryDisposition: { kind: "never" },
|
|
732
|
+
};
|
|
733
|
+
return {
|
|
734
|
+
...base,
|
|
735
|
+
state: "unknown",
|
|
736
|
+
reason: "unmapped-state",
|
|
737
|
+
diagnostic: "Container status alone does not identify a published native post. Reconcile stored publish results.",
|
|
738
|
+
};
|
|
739
|
+
},
|
|
740
|
+
async removeFromPlatform(ref, context) {
|
|
741
|
+
authorize(ref, context);
|
|
742
|
+
requireFacebookLogin("instagram.posts.removeFromPlatform");
|
|
743
|
+
await request(`/${encodeURIComponent(ref.postId)}`, context, undefined, {}, "DELETE");
|
|
744
|
+
},
|
|
745
|
+
},
|
|
746
|
+
comments: {
|
|
747
|
+
async list(ref, input, context) {
|
|
748
|
+
authorize(ref, context);
|
|
749
|
+
const query = {
|
|
750
|
+
fields: "id,text,timestamp,username",
|
|
751
|
+
limit: String(pageLimit(input.limit)),
|
|
752
|
+
};
|
|
753
|
+
if (input.cursor !== undefined)
|
|
754
|
+
query["after"] = input.cursor;
|
|
755
|
+
const result = validatedObject(await request(`/${encodeURIComponent(ref.postId)}/comments`, context, undefined, query), "instagram.comments.read");
|
|
756
|
+
return page(result, ["id", "text", "timestamp", "username"]);
|
|
757
|
+
},
|
|
758
|
+
async reply(ref, content, context) {
|
|
759
|
+
authorize(ref, context);
|
|
760
|
+
const result = object(await request(`/${encodeURIComponent(ref.commentId)}/replies`, context, {
|
|
761
|
+
message: content.text,
|
|
762
|
+
}));
|
|
763
|
+
return { ...ref, commentId: string(result["id"]) };
|
|
764
|
+
},
|
|
765
|
+
},
|
|
766
|
+
analytics: {
|
|
767
|
+
getAccountMetrics,
|
|
768
|
+
async getPostMetrics(ref, context) {
|
|
769
|
+
authorize(ref, context);
|
|
770
|
+
const media = validatedObject(await request(`/${encodeURIComponent(ref.postId)}`, context, undefined, {
|
|
771
|
+
fields: "media_product_type",
|
|
772
|
+
}), "instagram.analytics.read");
|
|
773
|
+
const result = validatedObject(await request(`/${encodeURIComponent(ref.postId)}/insights`, context, undefined, {
|
|
774
|
+
metric: media["media_product_type"] === "STORY"
|
|
775
|
+
? "shares,reach"
|
|
776
|
+
: "likes,comments,saved,shares,reach",
|
|
777
|
+
}), "instagram.analytics.read");
|
|
778
|
+
return array(result["data"]).flatMap((entry) => {
|
|
779
|
+
const row = object(entry);
|
|
780
|
+
const value = array(row["values"])[0];
|
|
781
|
+
if (!value)
|
|
782
|
+
return [];
|
|
783
|
+
const count = optionalNumber(object(value)["value"]);
|
|
784
|
+
return count === undefined
|
|
785
|
+
? []
|
|
786
|
+
: [
|
|
787
|
+
{
|
|
788
|
+
name: string(row["name"]),
|
|
789
|
+
value: count,
|
|
790
|
+
unit: "count",
|
|
791
|
+
period: "lifetime",
|
|
792
|
+
fetchedAt: now(),
|
|
793
|
+
freshness: "unknown",
|
|
794
|
+
source: `instagram:${apiVersion}`,
|
|
795
|
+
},
|
|
796
|
+
];
|
|
797
|
+
});
|
|
798
|
+
},
|
|
799
|
+
},
|
|
800
|
+
graph: {
|
|
801
|
+
async getProfile(account, input, context) {
|
|
802
|
+
authorize(account, context);
|
|
803
|
+
let profile;
|
|
804
|
+
if (input.profileId === undefined && input.handle === undefined) {
|
|
805
|
+
const own = validatedObject(await request(`/${encodeURIComponent(account.accountId)}`, context, undefined, {
|
|
806
|
+
fields: flavor === "facebook-login"
|
|
807
|
+
? "id,username,name,biography,profile_picture_url,followers_count,follows_count,media_count,website"
|
|
808
|
+
: "user_id,id,username,name,biography,profile_picture_url,followers_count,follows_count,media_count,website",
|
|
809
|
+
}), "profiles.read");
|
|
810
|
+
profile = publicFields(own, profileFields);
|
|
811
|
+
const ownId = optionalString(own["user_id"]);
|
|
812
|
+
if (ownId !== undefined)
|
|
813
|
+
profile = { ...profile, id: ownId };
|
|
814
|
+
}
|
|
815
|
+
else if (input.profileId !== undefined) {
|
|
816
|
+
profile = publicFields(await request(`/${encodeURIComponent(input.profileId)}`, context, undefined, {
|
|
817
|
+
fields: "id,username,name,biography,profile_picture_url,followers_count,follows_count,media_count,website",
|
|
818
|
+
}), profileFields);
|
|
819
|
+
}
|
|
820
|
+
else {
|
|
821
|
+
requireFacebookLogin("instagram.profiles.read");
|
|
822
|
+
const username = input.handle;
|
|
823
|
+
if (username === undefined)
|
|
824
|
+
throw new SocialError({
|
|
825
|
+
code: "invalid_input",
|
|
826
|
+
operation: "profiles.read",
|
|
827
|
+
message: "An Instagram profile ID or handle is required.",
|
|
828
|
+
});
|
|
829
|
+
if (!/^[A-Za-z0-9._]{1,30}$/.test(username))
|
|
830
|
+
throw new SocialError({
|
|
831
|
+
code: "invalid_input",
|
|
832
|
+
operation: "profiles.read",
|
|
833
|
+
message: "Instagram profile handles must contain only letters, numbers, periods, or underscores.",
|
|
834
|
+
});
|
|
835
|
+
const response = object(await request(`/${encodeURIComponent(account.accountId)}`, context, undefined, {
|
|
836
|
+
fields: `business_discovery.username(${username}){id,username,name,biography,profile_picture_url,followers_count,follows_count,media_count,website}`,
|
|
837
|
+
}));
|
|
838
|
+
profile = publicFields(response["business_discovery"], [
|
|
839
|
+
"id",
|
|
840
|
+
"username",
|
|
841
|
+
"name",
|
|
842
|
+
"biography",
|
|
843
|
+
"profile_picture_url",
|
|
844
|
+
"followers_count",
|
|
845
|
+
"follows_count",
|
|
846
|
+
"media_count",
|
|
847
|
+
"website",
|
|
848
|
+
]);
|
|
849
|
+
}
|
|
850
|
+
const id = string(profile["id"]);
|
|
851
|
+
return {
|
|
852
|
+
ref: profileRef({
|
|
853
|
+
backend: account.backend,
|
|
854
|
+
platform: "instagram",
|
|
855
|
+
accountId: account.accountId,
|
|
856
|
+
profileId: id,
|
|
857
|
+
}),
|
|
858
|
+
...(typeof profile["name"] === "string" ? { displayName: profile["name"] } : {}),
|
|
859
|
+
...(typeof profile["username"] === "string" ? { handle: profile["username"] } : {}),
|
|
860
|
+
...(typeof profile["profile_picture_url"] === "string"
|
|
861
|
+
? { avatarUrl: profile["profile_picture_url"] }
|
|
862
|
+
: {}),
|
|
863
|
+
...(typeof profile["biography"] === "string" ? { bio: profile["biography"] } : {}),
|
|
864
|
+
native: profile,
|
|
865
|
+
};
|
|
866
|
+
},
|
|
867
|
+
},
|
|
868
|
+
native: {
|
|
869
|
+
async moderateComment({ account, commentId, hidden, context }) {
|
|
870
|
+
authorize(account, context);
|
|
871
|
+
await request(`/${encodeURIComponent(commentId)}`, context, undefined, { hide: String(hidden) }, "POST");
|
|
872
|
+
},
|
|
873
|
+
async deleteComment({ account, commentId, context }) {
|
|
874
|
+
authorize(account, context);
|
|
875
|
+
await request(`/${encodeURIComponent(commentId)}`, context, undefined, {}, "DELETE");
|
|
876
|
+
},
|
|
877
|
+
async setCommentsEnabled({ account, mediaId, enabled, context }) {
|
|
878
|
+
authorize(account, context);
|
|
879
|
+
await request(`/${encodeURIComponent(mediaId)}`, context, undefined, { comment_enabled: String(enabled) }, "POST");
|
|
880
|
+
},
|
|
881
|
+
async listCommentReplies({ account, commentId, cursor, limit, context }) {
|
|
882
|
+
authorize(account, context);
|
|
883
|
+
const query = {
|
|
884
|
+
fields: "id,text,timestamp,username",
|
|
885
|
+
limit: String(pageLimit(limit)),
|
|
886
|
+
};
|
|
887
|
+
if (cursor !== undefined)
|
|
888
|
+
query["after"] = cursor;
|
|
889
|
+
return page(validatedObject(await request(`/${encodeURIComponent(commentId)}/replies`, context, undefined, query), "instagram.comments.replies.read"), ["id", "text", "timestamp", "username"]);
|
|
890
|
+
},
|
|
891
|
+
listMentions,
|
|
892
|
+
async mentionedMedia({ account, mediaId, context }) {
|
|
893
|
+
authorize(account, context);
|
|
894
|
+
requireFacebookLogin("instagram.mentions.read");
|
|
895
|
+
// oxlint-disable-next-line anti-slop/require-safety-comment-for-type-assertion -- validated provider object boundary.
|
|
896
|
+
return validatedObject(await request(`/${encodeURIComponent(account.accountId)}`, context, undefined, {
|
|
897
|
+
fields: `mentioned_media.media_id(${encodeURIComponent(mediaId)}){id,caption,media_type,media_url,timestamp,username,comments_count,like_count}`,
|
|
898
|
+
}), "instagram.mentions.read");
|
|
899
|
+
},
|
|
900
|
+
async mentionedComment({ account, commentId, context }) {
|
|
901
|
+
authorize(account, context);
|
|
902
|
+
requireFacebookLogin("instagram.mentions.read");
|
|
903
|
+
// oxlint-disable-next-line anti-slop/require-safety-comment-for-type-assertion -- validated provider object boundary.
|
|
904
|
+
return validatedObject(await request(`/${encodeURIComponent(account.accountId)}`, context, undefined, {
|
|
905
|
+
fields: `mentioned_comment.comment_id(${encodeURIComponent(commentId)}){id,text,timestamp,like_count,media}`,
|
|
906
|
+
}), "instagram.mentions.read");
|
|
907
|
+
},
|
|
908
|
+
async listTaggedMedia({ account, cursor, limit, context }) {
|
|
909
|
+
authorize(account, context);
|
|
910
|
+
requireFacebookLogin("instagram.mentions.read");
|
|
911
|
+
const query = {
|
|
912
|
+
fields: "id,caption,media_type,permalink,timestamp,username",
|
|
913
|
+
limit: String(pageLimit(limit)),
|
|
914
|
+
};
|
|
915
|
+
if (cursor !== undefined)
|
|
916
|
+
query["after"] = cursor;
|
|
917
|
+
return page(validatedObject(await request(`/${encodeURIComponent(account.accountId)}/tags`, context, undefined, query), "instagram.mentions.read"), ["id", "caption", "media_type", "permalink", "timestamp", "username"]);
|
|
918
|
+
},
|
|
919
|
+
async hashtagMedia({ account, hashtagId, kind, cursor, limit, context }) {
|
|
920
|
+
authorize(account, context);
|
|
921
|
+
requireFacebookLogin("instagram.hashtags.search");
|
|
922
|
+
const query = {
|
|
923
|
+
fields: "id,caption,media_type,permalink,timestamp,username",
|
|
924
|
+
limit: String(pageLimit(limit, 50)),
|
|
925
|
+
};
|
|
926
|
+
if (cursor !== undefined)
|
|
927
|
+
query["after"] = cursor;
|
|
928
|
+
return page(validatedObject(await request(`/${encodeURIComponent(hashtagId)}/${kind}_media`, context, undefined, query), "instagram.hashtags.search"), ["id", "caption", "media_type", "permalink", "timestamp", "username"]);
|
|
929
|
+
},
|
|
930
|
+
async businessDiscovery({ account, username, fields, context }) {
|
|
931
|
+
authorize(account, context);
|
|
932
|
+
requireFacebookLogin("instagram.profiles.businessDiscovery");
|
|
933
|
+
if (!/^[A-Za-z0-9._]{1,30}$/.test(username))
|
|
934
|
+
throw new SocialError({
|
|
935
|
+
code: "invalid_input",
|
|
936
|
+
operation: "instagram.profiles.businessDiscovery",
|
|
937
|
+
message: "Business discovery usernames must contain only letters, numbers, periods, or underscores.",
|
|
938
|
+
});
|
|
939
|
+
const selectedFields = fields ??
|
|
940
|
+
`business_discovery.username(${username}){id,username,name,biography,followers_count,media_count,profile_picture_url,media.limit(25){id,caption,media_type,permalink,timestamp}}`;
|
|
941
|
+
// oxlint-disable-next-line anti-slop/require-safety-comment-for-type-assertion -- validated provider object boundary.
|
|
942
|
+
return object(await request(`/${encodeURIComponent(account.accountId)}`, context, undefined, {
|
|
943
|
+
fields: selectedFields,
|
|
944
|
+
}));
|
|
945
|
+
},
|
|
946
|
+
publishContainer,
|
|
947
|
+
async publishCarousel(account, children, caption, context) {
|
|
948
|
+
authorize(account, context);
|
|
949
|
+
const workflow = await workflows.create({
|
|
950
|
+
backend: account.backend,
|
|
951
|
+
accountId: account.accountId,
|
|
952
|
+
childIds: [...children],
|
|
953
|
+
caption,
|
|
954
|
+
stage: "children",
|
|
955
|
+
});
|
|
956
|
+
return resumeWorkflow(workflow, account, context);
|
|
957
|
+
},
|
|
958
|
+
async resumePublication(account, workflowId, context) {
|
|
959
|
+
authorize(account, context);
|
|
960
|
+
const workflow = await workflows.get(workflowId);
|
|
961
|
+
if (!workflow ||
|
|
962
|
+
workflow.backend !== context.backendInstance ||
|
|
963
|
+
workflow.accountId !== account.accountId)
|
|
964
|
+
throw new SocialError({
|
|
965
|
+
code: "unauthorized",
|
|
966
|
+
operation: "instagram.posts.resume",
|
|
967
|
+
message: "Instagram workflow handle is not authorized for this account.",
|
|
968
|
+
});
|
|
969
|
+
return resumeWorkflow(workflow, account, context);
|
|
970
|
+
},
|
|
971
|
+
async publishReel({ account, videoUrl, caption, context }) {
|
|
972
|
+
authorize(account, context);
|
|
973
|
+
const created = object(await request(`/${encodeURIComponent(account.accountId)}/media`, context, {
|
|
974
|
+
media_type: "REELS",
|
|
975
|
+
video_url: videoUrl,
|
|
976
|
+
// oxlint-disable-next-line anti-slop/no-conditional-empty-object-spread -- validated boundary or fixture contract.
|
|
977
|
+
...(caption ? { caption } : {}),
|
|
978
|
+
}));
|
|
979
|
+
return publishContainer(account, string(created["id"]), context);
|
|
980
|
+
},
|
|
981
|
+
async publishStory({ account, mediaUrl, videoUrl, context }) {
|
|
982
|
+
authorize(account, context);
|
|
983
|
+
const created = object(await request(`/${encodeURIComponent(account.accountId)}/media`, context, {
|
|
984
|
+
media_type: "STORIES",
|
|
985
|
+
...(videoUrl === undefined ? { image_url: mediaUrl } : { video_url: videoUrl }),
|
|
986
|
+
}));
|
|
987
|
+
return publishContainer(account, string(created["id"]), context);
|
|
988
|
+
},
|
|
989
|
+
async deletePost({ account, postId, context }) {
|
|
990
|
+
authorize(account, context);
|
|
991
|
+
requireFacebookLogin("instagram.posts.delete");
|
|
992
|
+
await request(`/${encodeURIComponent(postId)}`, context, undefined, {}, "DELETE");
|
|
993
|
+
},
|
|
994
|
+
async hashtagSearch({ account, hashtag, context }) {
|
|
995
|
+
authorize(account, context);
|
|
996
|
+
requireFacebookLogin("instagram.hashtags.search");
|
|
997
|
+
const normalized = hashtag.replace(/^#/, "").trim();
|
|
998
|
+
if (!normalized)
|
|
999
|
+
throw new SocialError({
|
|
1000
|
+
code: "invalid_input",
|
|
1001
|
+
operation: "instagram.hashtags.search",
|
|
1002
|
+
message: "Instagram hashtag search requires a non-empty hashtag name.",
|
|
1003
|
+
});
|
|
1004
|
+
const result = validatedObject(await request("/ig_hashtag_search", context, undefined, {
|
|
1005
|
+
user_id: account.accountId,
|
|
1006
|
+
q: normalized,
|
|
1007
|
+
}), "instagram.hashtags.search");
|
|
1008
|
+
return {
|
|
1009
|
+
data: array(result["data"]).map((entry) => {
|
|
1010
|
+
const row = object(entry);
|
|
1011
|
+
return { id: string(row["id"]) };
|
|
1012
|
+
}),
|
|
1013
|
+
};
|
|
1014
|
+
},
|
|
1015
|
+
async publishingLimit({ account, context }) {
|
|
1016
|
+
authorize(account, context);
|
|
1017
|
+
// SAFETY: object() establishes an object response; this native method intentionally
|
|
1018
|
+
// preserves Meta's provider-specific config object after validating its outer shape.
|
|
1019
|
+
return object(await request(`/${encodeURIComponent(account.accountId)}/content_publishing_limit`, context, undefined, { fields: "config,quota_usage" }));
|
|
1020
|
+
},
|
|
1021
|
+
async mentions({ account, cursor, limit, context }) {
|
|
1022
|
+
return listMentions({
|
|
1023
|
+
account,
|
|
1024
|
+
...(cursor === undefined ? {} : { cursor }),
|
|
1025
|
+
...(limit === undefined ? {} : { limit }),
|
|
1026
|
+
context,
|
|
1027
|
+
});
|
|
1028
|
+
},
|
|
1029
|
+
},
|
|
1030
|
+
});
|
|
1031
|
+
}
|