@opencoredev/social-sdk 0.2.1 → 0.4.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/dist/cli-request.d.ts +15 -0
- package/dist/cli-request.js +193 -0
- package/dist/cli.d.ts +4 -3
- package/dist/cli.js +19 -21
- package/dist/cloud/common.d.ts +7 -6
- package/dist/cloud/common.js +35 -54
- package/dist/cloud/lifecycle.js +31 -35
- package/dist/cloud/media.d.ts +2 -2
- package/dist/cloud/media.js +13 -3
- package/dist/cloud/outcomes.d.ts +4 -3
- package/dist/cloud/outcomes.js +8 -15
- package/dist/cloud/post-for-me.js +41 -49
- package/dist/cloud/zernio.js +58 -98
- package/dist/core/client.js +79 -99
- package/dist/core/fields.d.ts +14 -0
- package/dist/core/fields.js +14 -0
- package/dist/core/idempotency.d.ts +7 -2
- package/dist/core/idempotency.js +37 -20
- package/dist/core/pagination.js +8 -7
- package/dist/core/types.d.ts +3 -2
- package/dist/platforms/bluesky.d.ts +65 -1
- package/dist/platforms/bluesky.js +675 -276
- package/dist/platforms/instagram.d.ts +2 -0
- package/dist/platforms/instagram.js +130 -105
- package/dist/platforms/linkedin.d.ts +58 -1
- package/dist/platforms/linkedin.js +877 -107
- package/dist/platforms/threads.d.ts +13 -1
- package/dist/platforms/threads.js +204 -302
- package/dist/platforms/tiktok.d.ts +4 -0
- package/dist/platforms/tiktok.js +140 -124
- package/dist/platforms/webhook-adapter.d.ts +9 -0
- package/dist/platforms/webhook-adapter.js +24 -0
- package/dist/platforms/x-engagement.js +7 -12
- package/dist/platforms/x-stream.d.ts +83 -0
- package/dist/platforms/x-stream.js +350 -0
- package/dist/platforms/x.d.ts +87 -0
- package/dist/platforms/x.js +648 -120
- package/dist/platforms/youtube-upload.d.ts +1 -1
- package/dist/platforms/youtube-upload.js +6 -2
- package/dist/platforms/youtube.d.ts +28 -4
- package/dist/platforms/youtube.js +291 -133
- package/dist/server/bluesky-oauth.d.ts +177 -0
- package/dist/server/bluesky-oauth.js +1229 -0
- package/dist/server/connections.d.ts +14 -0
- package/dist/server/connections.js +10 -2
- package/dist/server/egress.d.ts +14 -0
- package/dist/server/egress.js +115 -0
- package/dist/server/oauth-internal.d.ts +6 -0
- package/dist/server/oauth-internal.js +66 -0
- package/dist/server/oauth.d.ts +1 -1
- package/dist/server/oauth.js +46 -99
- package/dist/server/webhooks.d.ts +136 -3
- package/dist/server/webhooks.js +639 -25
- package/dist/testing/index.js +14 -28
- package/dist/transport/http.d.ts +1 -1
- package/dist/transport/http.js +0 -1
- package/dist/transport/json.d.ts +7 -0
- package/dist/transport/json.js +32 -4
- package/dist/transport/upload.d.ts +1 -1
- package/dist/transport/upload.js +46 -38
- package/dist/transport/validation.d.ts +16 -5
- package/dist/transport/validation.js +29 -7
- package/package.json +2 -2
package/dist/platforms/x.js
CHANGED
|
@@ -1,19 +1,28 @@
|
|
|
1
|
-
/* oxlint-disable anti-slop/no-conditional-empty-object-spread, anti-slop/no-runtime-typeof, anti-slop/require-readable-spacing, anti-slop/require-safety-comment-for-type-assertion -- validated external boundary or fixture contract. */
|
|
2
1
|
import { remainingBudget } from "../transport/budget.js";
|
|
2
|
+
import { definedFields } from "../core/fields.js";
|
|
3
3
|
import { isValidXText } from "./x-text.js";
|
|
4
4
|
import { defineAdapter } from "../core/adapter.js";
|
|
5
5
|
import { connectedAccountRef, profileRef } from "../core/types.js";
|
|
6
6
|
import { SocialError } from "../core/errors.js";
|
|
7
|
-
import { managedHttp, publicFields } from "../cloud/common.js";
|
|
7
|
+
import { managedHttp, optionsObject, publicFields } from "../cloud/common.js";
|
|
8
|
+
import { verifyXWebhook } from "../server/webhooks.js";
|
|
9
|
+
import { directWebhooks, webhookCapability } from "./webhook-adapter.js";
|
|
8
10
|
import { createHttp, HttpError } from "../transport/http.js";
|
|
9
|
-
import {
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
// 53-bit conversation ID hashes, 11 base36 characters each. listConversations returns at
|
|
13
|
-
// most 1,200 distinct conversations, which keeps its cursor well under the client's 16,384
|
|
14
|
-
// character cursor limit.
|
|
11
|
+
import { isJsonValue } from "../transport/json.js";
|
|
12
|
+
import { parseRulesUpdate, parseStreamRule, readFilteredStream, validateRuleIds, validateRuleInput, } from "./x-stream.js";
|
|
13
|
+
import { array, isString, object, optionalBoolean, optionalNumber, optionalString, string, } from "../transport/validation.js";
|
|
15
14
|
const conversationHashWidth = 11;
|
|
16
15
|
const maxConversationHashes = 1200;
|
|
16
|
+
const replyFields = [
|
|
17
|
+
"id",
|
|
18
|
+
"text",
|
|
19
|
+
"author_id",
|
|
20
|
+
"created_at",
|
|
21
|
+
"conversation_id",
|
|
22
|
+
"in_reply_to_user_id",
|
|
23
|
+
"referenced_tweets",
|
|
24
|
+
"public_metrics",
|
|
25
|
+
];
|
|
17
26
|
function conversationHash(id) {
|
|
18
27
|
let h1 = 0xdeadbeef;
|
|
19
28
|
let h2 = 0x41c6ce57;
|
|
@@ -28,11 +37,11 @@ function conversationHash(id) {
|
|
|
28
37
|
return value.toString(36).padStart(conversationHashWidth, "0");
|
|
29
38
|
}
|
|
30
39
|
export { xLike, xUnlike } from "./x-engagement.js";
|
|
40
|
+
export { xStreamDefaultStallTimeoutMs, } from "./x-stream.js";
|
|
31
41
|
export function x(options) {
|
|
32
42
|
const request = managedHttp("https://api.x.com", {
|
|
33
43
|
apiKey: options.auth.accessToken ?? "app-auth-placeholder",
|
|
34
|
-
|
|
35
|
-
...(options.fetch ? { fetch: options.fetch } : {}),
|
|
44
|
+
...definedFields({ fetch: options.fetch }),
|
|
36
45
|
});
|
|
37
46
|
const appRequest = () => {
|
|
38
47
|
if (!options.appBearerToken?.trim())
|
|
@@ -43,8 +52,7 @@ export function x(options) {
|
|
|
43
52
|
});
|
|
44
53
|
return managedHttp("https://api.x.com", {
|
|
45
54
|
apiKey: options.appBearerToken,
|
|
46
|
-
|
|
47
|
-
...(options.fetch ? { fetch: options.fetch } : {}),
|
|
55
|
+
...definedFields({ fetch: options.fetch }),
|
|
48
56
|
});
|
|
49
57
|
};
|
|
50
58
|
const requireUserToken = (operation) => {
|
|
@@ -61,7 +69,7 @@ export function x(options) {
|
|
|
61
69
|
: options.auth.accessToken?.trim()
|
|
62
70
|
? request
|
|
63
71
|
: appRequest();
|
|
64
|
-
const http = createHttp(
|
|
72
|
+
const http = createHttp(definedFields({ fetch: options.fetch }));
|
|
65
73
|
const now = () => (options.clock?.() ?? new Date()).toISOString();
|
|
66
74
|
const authorize = (ref, context) => {
|
|
67
75
|
if (ref.backend !== context.backendInstance ||
|
|
@@ -90,15 +98,7 @@ export function x(options) {
|
|
|
90
98
|
accountId: options.auth.userId,
|
|
91
99
|
},
|
|
92
100
|
displayName: string(user["name"]),
|
|
93
|
-
|
|
94
|
-
// oxlint-disable-next-line anti-slop/no-runtime-typeof -- validated boundary or fixture contract.
|
|
95
|
-
// oxlint-disable-next-line anti-slop/no-conditional-empty-object-spread -- provider payload is validated at this adapter boundary.
|
|
96
|
-
// oxlint-disable-next-line anti-slop/no-runtime-typeof -- validated external boundary or fixture contract.
|
|
97
|
-
// oxlint-disable-next-line anti-slop/no-conditional-empty-object-spread -- validated external boundary or fixture contract.
|
|
98
|
-
// oxlint-disable-next-line anti-slop/no-runtime-typeof -- validated external boundary or fixture contract.
|
|
99
|
-
// oxlint-disable-next-line anti-slop/no-conditional-empty-object-spread -- validated external boundary or fixture contract.
|
|
100
|
-
// oxlint-disable-next-line anti-slop/no-runtime-typeof -- validated external boundary or fixture contract.
|
|
101
|
-
...(typeof user["username"] === "string" ? { handle: user["username"] } : {}),
|
|
101
|
+
...definedFields({ handle: optionalString(user["username"]) }),
|
|
102
102
|
status: "connected",
|
|
103
103
|
};
|
|
104
104
|
}
|
|
@@ -113,7 +113,6 @@ export function x(options) {
|
|
|
113
113
|
operation: "posts.read",
|
|
114
114
|
message: "X post identity or author does not match the declared reference.",
|
|
115
115
|
});
|
|
116
|
-
// oxlint-disable-next-line anti-slop/require-safety-comment-for-type-assertion -- validated boundary or fixture contract.
|
|
117
116
|
return result;
|
|
118
117
|
}
|
|
119
118
|
async function listPosts(account, input, context) {
|
|
@@ -127,10 +126,10 @@ export function x(options) {
|
|
|
127
126
|
});
|
|
128
127
|
const result = object(await request(`/2/users/${encodeURIComponent(account.accountId)}/tweets`, context, undefined, {
|
|
129
128
|
"tweet.fields": "id,text,author_id,created_at,conversation_id",
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
129
|
+
...definedFields({
|
|
130
|
+
pagination_token: input.cursor,
|
|
131
|
+
max_results: input.limit?.toString(),
|
|
132
|
+
}),
|
|
134
133
|
}));
|
|
135
134
|
const items = (result["data"] === undefined ? [] : array(result["data"])).map((entry) => {
|
|
136
135
|
const row = object(entry);
|
|
@@ -147,8 +146,7 @@ export function x(options) {
|
|
|
147
146
|
const nextCursor = optionalString(meta["next_token"]);
|
|
148
147
|
return {
|
|
149
148
|
items,
|
|
150
|
-
|
|
151
|
-
...(nextCursor === undefined ? {} : { nextCursor }),
|
|
149
|
+
...definedFields({ nextCursor }),
|
|
152
150
|
};
|
|
153
151
|
}
|
|
154
152
|
async function searchPosts(account, input, context, nativeInput) {
|
|
@@ -207,28 +205,16 @@ export function x(options) {
|
|
|
207
205
|
]
|
|
208
206
|
.filter((field, index, fields) => fields.indexOf(field) === index)
|
|
209
207
|
.join(","),
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
// oxlint-disable-next-line anti-slop/no-conditional-empty-object-spread -- validated boundary or fixture contract.
|
|
221
|
-
...(nativeInput?.expansions === undefined
|
|
222
|
-
? {}
|
|
223
|
-
: { expansions: nativeInput.expansions.join(",") }),
|
|
224
|
-
// oxlint-disable-next-line anti-slop/no-conditional-empty-object-spread -- validated boundary or fixture contract.
|
|
225
|
-
...(nativeInput?.userFields === undefined
|
|
226
|
-
? {}
|
|
227
|
-
: { "user.fields": nativeInput.userFields.join(",") }),
|
|
228
|
-
// oxlint-disable-next-line anti-slop/no-conditional-empty-object-spread -- validated boundary or fixture contract.
|
|
229
|
-
...(nativeInput?.mediaFields === undefined
|
|
230
|
-
? {}
|
|
231
|
-
: { "media.fields": nativeInput.mediaFields.join(",") }),
|
|
208
|
+
...definedFields({
|
|
209
|
+
next_token: input.cursor,
|
|
210
|
+
max_results: input.limit?.toString(),
|
|
211
|
+
start_time: input.startTime,
|
|
212
|
+
end_time: input.endTime,
|
|
213
|
+
sort_order: nativeInput?.sortOrder,
|
|
214
|
+
expansions: nativeInput?.expansions?.join(","),
|
|
215
|
+
"user.fields": nativeInput?.userFields?.join(","),
|
|
216
|
+
"media.fields": nativeInput?.mediaFields?.join(","),
|
|
217
|
+
}),
|
|
232
218
|
}));
|
|
233
219
|
const requestedFields = nativeInput?.tweetFields ?? [
|
|
234
220
|
"id",
|
|
@@ -244,22 +230,96 @@ export function x(options) {
|
|
|
244
230
|
];
|
|
245
231
|
const items = (result["data"] === undefined ? [] : array(result["data"])).map((entry) => {
|
|
246
232
|
const row = object(entry);
|
|
247
|
-
|
|
233
|
+
const picked = {};
|
|
234
|
+
// A Set keeps first-seen order, so "id" and "text" lead and repeated fields appear once.
|
|
235
|
+
for (const field of new Set(["id", "text", ...requestedFields])) {
|
|
236
|
+
const value = row[field];
|
|
237
|
+
if (value !== undefined)
|
|
238
|
+
picked[field] = value;
|
|
239
|
+
}
|
|
240
|
+
return picked;
|
|
248
241
|
});
|
|
249
242
|
const meta = result["meta"] === undefined ? {} : object(result["meta"]);
|
|
250
243
|
const nextCursor = optionalString(meta["next_token"]);
|
|
251
244
|
const includes = result["includes"];
|
|
252
245
|
return {
|
|
253
246
|
items,
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
247
|
+
...definedFields({
|
|
248
|
+
nextCursor,
|
|
249
|
+
metadata: includes === undefined ? undefined : { includes: object(includes) },
|
|
250
|
+
}),
|
|
251
|
+
};
|
|
252
|
+
}
|
|
253
|
+
// Replies come from recent search with the standalone `conversation_id:` operator, which is
|
|
254
|
+
// the method X documents for reading a conversation. Sources, accessed 2026-09-24:
|
|
255
|
+
// https://docs.x.com/x-api/fundamentals/conversation-id
|
|
256
|
+
// https://docs.x.com/x-api/posts/search/integrate/operators
|
|
257
|
+
// https://docs.x.com/x-api/posts/search-recent-posts (max_results 10-100, next_token)
|
|
258
|
+
// https://docs.x.com/x-api/posts/search/introduction (recent search covers the last 7 days)
|
|
259
|
+
// https://docs.x.com/x-api/fundamentals/rate-limits (450/15min per app, 300/15min per user)
|
|
260
|
+
async function listReplies(post, input, context) {
|
|
261
|
+
authorize(post, context);
|
|
262
|
+
if (!/^[0-9]{1,19}$/.test(post.postId))
|
|
263
|
+
throw new SocialError({
|
|
264
|
+
code: "invalid_input",
|
|
265
|
+
operation: "comments.read",
|
|
266
|
+
message: "X post IDs must be numeric.",
|
|
267
|
+
});
|
|
268
|
+
if (input.limit !== undefined &&
|
|
269
|
+
(!Number.isSafeInteger(input.limit) || input.limit < 10 || input.limit > 100))
|
|
270
|
+
throw new SocialError({
|
|
271
|
+
code: "invalid_input",
|
|
272
|
+
operation: "comments.read",
|
|
273
|
+
message: "X reply limits must be integers from 10 through 100.",
|
|
274
|
+
});
|
|
275
|
+
const query = {
|
|
276
|
+
query: `conversation_id:${post.postId}`,
|
|
277
|
+
"tweet.fields": replyFields.join(","),
|
|
262
278
|
};
|
|
279
|
+
if (input.cursor !== undefined) {
|
|
280
|
+
query["next_token"] = input.cursor;
|
|
281
|
+
}
|
|
282
|
+
if (input.limit !== undefined) {
|
|
283
|
+
query["max_results"] = String(input.limit);
|
|
284
|
+
}
|
|
285
|
+
const result = object(await (options.auth.accessToken?.trim() ? request : appRequest())("/2/tweets/search/recent", context, undefined, query));
|
|
286
|
+
const items = (result["data"] === undefined ? [] : array(result["data"])).flatMap((entry) => {
|
|
287
|
+
const row = object(entry);
|
|
288
|
+
const id = string(row["id"]);
|
|
289
|
+
if (row["conversation_id"] !== post.postId)
|
|
290
|
+
throw new SocialError({
|
|
291
|
+
code: "unauthorized",
|
|
292
|
+
operation: "comments.read",
|
|
293
|
+
message: "X returned a post from a different conversation.",
|
|
294
|
+
});
|
|
295
|
+
// The conversation root shares its own conversation_id; it is not a reply.
|
|
296
|
+
if (id === post.postId)
|
|
297
|
+
return [];
|
|
298
|
+
const references = row["referenced_tweets"] === undefined
|
|
299
|
+
? undefined
|
|
300
|
+
: array(row["referenced_tweets"]).map((reference) => {
|
|
301
|
+
const item = object(reference);
|
|
302
|
+
return { type: string(item["type"]), id: string(item["id"]) };
|
|
303
|
+
});
|
|
304
|
+
const counts = row["public_metrics"] === undefined ? undefined : object(row["public_metrics"]);
|
|
305
|
+
const metrics = counts === undefined
|
|
306
|
+
? undefined
|
|
307
|
+
: Object.fromEntries(Object.keys(counts).flatMap((name) => {
|
|
308
|
+
const value = optionalNumber(counts[name]);
|
|
309
|
+
return value === undefined ? [] : [[name, value]];
|
|
310
|
+
}));
|
|
311
|
+
const itemFields = definedFields({
|
|
312
|
+
...publicFields(row, replyFields),
|
|
313
|
+
referenced_tweets: references,
|
|
314
|
+
public_metrics: metrics,
|
|
315
|
+
});
|
|
316
|
+
return [itemFields];
|
|
317
|
+
});
|
|
318
|
+
const meta = result["meta"] === undefined ? {} : object(result["meta"]);
|
|
319
|
+
const nextCursor = optionalString(meta["next_token"]);
|
|
320
|
+
if (nextCursor === undefined)
|
|
321
|
+
return { items };
|
|
322
|
+
return { items, nextCursor };
|
|
263
323
|
}
|
|
264
324
|
async function pageRequest(path, account, input, context, query = {}, fields = "id,name,username,description,created_at,public_metrics", resource = "users") {
|
|
265
325
|
authorize(account, context);
|
|
@@ -285,13 +345,12 @@ export function x(options) {
|
|
|
285
345
|
: fields
|
|
286
346
|
? { "user.fields": fields }
|
|
287
347
|
: {}),
|
|
288
|
-
...(
|
|
289
|
-
...(input.limit === undefined ? {} : { max_results: String(input.limit) }),
|
|
348
|
+
...definedFields({ pagination_token: input.cursor, max_results: input.limit?.toString() }),
|
|
290
349
|
}));
|
|
291
|
-
const items = (result["data"] === undefined ? [] : array(result["data"])).map(
|
|
350
|
+
const items = (result["data"] === undefined ? [] : array(result["data"])).map(object);
|
|
292
351
|
const meta = result["meta"] === undefined ? {} : object(result["meta"]);
|
|
293
352
|
const nextCursor = optionalString(meta["next_token"]);
|
|
294
|
-
return { items, ...(
|
|
353
|
+
return { items, ...definedFields({ nextCursor }) };
|
|
295
354
|
}
|
|
296
355
|
async function getAccountMetrics(account, context) {
|
|
297
356
|
authorize(account, context);
|
|
@@ -341,8 +400,7 @@ export function x(options) {
|
|
|
341
400
|
"tweet.fields": "conversation_id",
|
|
342
401
|
}))["data"]);
|
|
343
402
|
if (root["id"] !== ref.postId ||
|
|
344
|
-
|
|
345
|
-
(typeof parent["conversation_id"] === "string" &&
|
|
403
|
+
(isString(parent["conversation_id"]) &&
|
|
346
404
|
parent["conversation_id"] !== root["conversation_id"]))
|
|
347
405
|
throw new SocialError({
|
|
348
406
|
code: "unauthorized",
|
|
@@ -372,8 +430,7 @@ export function x(options) {
|
|
|
372
430
|
headers: { Authorization: `Bearer ${options.auth.accessToken}` },
|
|
373
431
|
body,
|
|
374
432
|
timeoutMs: remainingBudget(context),
|
|
375
|
-
|
|
376
|
-
...(context.signal ? { signal: context.signal } : {}),
|
|
433
|
+
...definedFields({ signal: context.signal }),
|
|
377
434
|
});
|
|
378
435
|
}
|
|
379
436
|
catch (error) {
|
|
@@ -396,6 +453,265 @@ export function x(options) {
|
|
|
396
453
|
});
|
|
397
454
|
return string(data["id"]);
|
|
398
455
|
}
|
|
456
|
+
const xChunkBytes = 1024 * 1024;
|
|
457
|
+
const xMaxVideoBytes = 512 * 1024 * 1024;
|
|
458
|
+
const xMaxGifBytes = 15 * 1024 * 1024;
|
|
459
|
+
const xMaxStatusPolls = 30;
|
|
460
|
+
function chunkedCategory(media) {
|
|
461
|
+
if (media.source.kind !== "blob")
|
|
462
|
+
return undefined;
|
|
463
|
+
if (media.kind === "video" && media.mimeType === "video/mp4")
|
|
464
|
+
return "tweet_video";
|
|
465
|
+
if (media.kind === "image" && media.mimeType === "image/gif")
|
|
466
|
+
return "tweet_gif";
|
|
467
|
+
return undefined;
|
|
468
|
+
}
|
|
469
|
+
function chunkedLimits(media, category) {
|
|
470
|
+
const size = media.source.kind === "blob" ? media.source.blob.size : 0;
|
|
471
|
+
const maxBytes = category === "tweet_video" ? xMaxVideoBytes : xMaxGifBytes;
|
|
472
|
+
if (size <= 0 || size > maxBytes)
|
|
473
|
+
throw new SocialError({
|
|
474
|
+
code: "invalid_input",
|
|
475
|
+
operation: "media.upload",
|
|
476
|
+
message: category === "tweet_video"
|
|
477
|
+
? "X video uploads require a non-empty MP4 Blob up to 512 MiB."
|
|
478
|
+
: "X GIF uploads require a non-empty GIF Blob up to 15 MiB.",
|
|
479
|
+
});
|
|
480
|
+
}
|
|
481
|
+
function mediaHttpError(error) {
|
|
482
|
+
if (error.kind === "cancelled")
|
|
483
|
+
return new SocialError({
|
|
484
|
+
code: "cancelled",
|
|
485
|
+
operation: "media.upload",
|
|
486
|
+
message: "X media upload was cancelled before completion.",
|
|
487
|
+
upstreamStatus: error.status,
|
|
488
|
+
});
|
|
489
|
+
if (error.kind === "timeout")
|
|
490
|
+
return new SocialError({
|
|
491
|
+
code: "timeout",
|
|
492
|
+
operation: "media.upload",
|
|
493
|
+
message: "X media upload exceeded its elapsed budget. Reconcile before retrying.",
|
|
494
|
+
upstreamStatus: error.status,
|
|
495
|
+
retryDisposition: { kind: "never" },
|
|
496
|
+
});
|
|
497
|
+
if (error.status === 401)
|
|
498
|
+
return new SocialError({
|
|
499
|
+
code: "reconnect_required",
|
|
500
|
+
operation: "media.upload",
|
|
501
|
+
message: "X rejected the media upload credentials. Reconnect before retrying.",
|
|
502
|
+
upstreamStatus: error.status,
|
|
503
|
+
retryDisposition: { kind: "after-reconnect" },
|
|
504
|
+
});
|
|
505
|
+
if (error.status === 429)
|
|
506
|
+
return new SocialError({
|
|
507
|
+
code: "rate_limited",
|
|
508
|
+
operation: "media.upload",
|
|
509
|
+
message: "X rate-limited the media upload.",
|
|
510
|
+
upstreamStatus: error.status,
|
|
511
|
+
retryDisposition: error.retryAfterMs === undefined
|
|
512
|
+
? { kind: "never" }
|
|
513
|
+
: { kind: "after-delay", delayMs: error.retryAfterMs },
|
|
514
|
+
});
|
|
515
|
+
if (error.status === 413)
|
|
516
|
+
return new SocialError({
|
|
517
|
+
code: "media_error",
|
|
518
|
+
operation: "media.upload",
|
|
519
|
+
message: "Media chunk rejected by X (payload too large).",
|
|
520
|
+
upstreamStatus: error.status,
|
|
521
|
+
retryDisposition: { kind: "never" },
|
|
522
|
+
});
|
|
523
|
+
return new SocialError({
|
|
524
|
+
code: "media_error",
|
|
525
|
+
operation: "media.upload",
|
|
526
|
+
message: error.message,
|
|
527
|
+
upstreamStatus: error.status,
|
|
528
|
+
retryDisposition: { kind: "never" },
|
|
529
|
+
});
|
|
530
|
+
}
|
|
531
|
+
async function chunkedPost(path, body, context) {
|
|
532
|
+
requireUserToken("media.upload");
|
|
533
|
+
let result;
|
|
534
|
+
try {
|
|
535
|
+
result = await http({
|
|
536
|
+
url: new URL(`https://api.x.com${path}`),
|
|
537
|
+
method: "POST",
|
|
538
|
+
headers: body === undefined
|
|
539
|
+
? { Authorization: `Bearer ${options.auth.accessToken}` }
|
|
540
|
+
: {
|
|
541
|
+
Authorization: `Bearer ${options.auth.accessToken}`,
|
|
542
|
+
"Content-Type": "application/json",
|
|
543
|
+
},
|
|
544
|
+
timeoutMs: remainingBudget(context),
|
|
545
|
+
...definedFields({
|
|
546
|
+
body: body === undefined ? undefined : JSON.stringify(body),
|
|
547
|
+
signal: context.signal,
|
|
548
|
+
}),
|
|
549
|
+
});
|
|
550
|
+
}
|
|
551
|
+
catch (error) {
|
|
552
|
+
if (!(error instanceof HttpError))
|
|
553
|
+
throw error;
|
|
554
|
+
throw mediaHttpError(error);
|
|
555
|
+
}
|
|
556
|
+
return object(object(result)["data"]);
|
|
557
|
+
}
|
|
558
|
+
async function appendChunk(mediaId, segmentIndex, chunk, context) {
|
|
559
|
+
requireUserToken("media.upload");
|
|
560
|
+
const body = new FormData();
|
|
561
|
+
body.set("segment_index", String(segmentIndex));
|
|
562
|
+
body.set("media", chunk, `chunk-${segmentIndex}`);
|
|
563
|
+
try {
|
|
564
|
+
await http({
|
|
565
|
+
url: new URL(`https://api.x.com/2/media/upload/${encodeURIComponent(mediaId)}/append`),
|
|
566
|
+
method: "POST",
|
|
567
|
+
headers: { Authorization: `Bearer ${options.auth.accessToken}` },
|
|
568
|
+
body,
|
|
569
|
+
timeoutMs: remainingBudget(context),
|
|
570
|
+
...definedFields({ signal: context.signal }),
|
|
571
|
+
});
|
|
572
|
+
}
|
|
573
|
+
catch (error) {
|
|
574
|
+
if (!(error instanceof HttpError))
|
|
575
|
+
throw error;
|
|
576
|
+
throw mediaHttpError(error);
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
async function readMediaStatus(mediaId, context) {
|
|
580
|
+
requireUserToken("media.upload");
|
|
581
|
+
let result;
|
|
582
|
+
try {
|
|
583
|
+
result = await http({
|
|
584
|
+
url: new URL(`https://api.x.com/2/media/upload?command=STATUS&media_id=${encodeURIComponent(mediaId)}`),
|
|
585
|
+
method: "GET",
|
|
586
|
+
headers: { Authorization: `Bearer ${options.auth.accessToken}` },
|
|
587
|
+
timeoutMs: remainingBudget(context),
|
|
588
|
+
...definedFields({ signal: context.signal }),
|
|
589
|
+
});
|
|
590
|
+
}
|
|
591
|
+
catch (error) {
|
|
592
|
+
if (!(error instanceof HttpError))
|
|
593
|
+
throw error;
|
|
594
|
+
throw mediaHttpError(error);
|
|
595
|
+
}
|
|
596
|
+
const data = object(object(result)["data"]);
|
|
597
|
+
const processing = data["processing_info"];
|
|
598
|
+
if (processing === undefined)
|
|
599
|
+
return { state: "succeeded", checkAfterSecs: 0 };
|
|
600
|
+
return processingState(object(processing));
|
|
601
|
+
}
|
|
602
|
+
function processingState(info) {
|
|
603
|
+
const checkAfter = optionalNumber(info["check_after_secs"]);
|
|
604
|
+
return {
|
|
605
|
+
state: optionalString(info["state"]) ?? "pending",
|
|
606
|
+
// X always sends check_after_secs while processing; a missing value must not spin the poll.
|
|
607
|
+
checkAfterSecs: checkAfter !== undefined && Number.isFinite(checkAfter) && checkAfter >= 0 ? checkAfter : 1,
|
|
608
|
+
};
|
|
609
|
+
}
|
|
610
|
+
function uploadCancelled() {
|
|
611
|
+
return new SocialError({
|
|
612
|
+
code: "cancelled",
|
|
613
|
+
operation: "media.upload",
|
|
614
|
+
message: "X media upload was cancelled before completion.",
|
|
615
|
+
});
|
|
616
|
+
}
|
|
617
|
+
function throwIfUploadCancelled(context) {
|
|
618
|
+
if (context.signal?.aborted)
|
|
619
|
+
throw uploadCancelled();
|
|
620
|
+
}
|
|
621
|
+
/** Waits for X's processing hint without outliving the shared operation budget. */
|
|
622
|
+
function processingWait(milliseconds, context) {
|
|
623
|
+
throwIfUploadCancelled(context);
|
|
624
|
+
if (milliseconds <= 0)
|
|
625
|
+
return Promise.resolve();
|
|
626
|
+
if (milliseconds >= remainingBudget(context))
|
|
627
|
+
throw new SocialError({
|
|
628
|
+
code: "timeout",
|
|
629
|
+
operation: "media.upload",
|
|
630
|
+
message: "X media processing needs longer than the remaining elapsed budget. No post was created.",
|
|
631
|
+
retryDisposition: { kind: "never" },
|
|
632
|
+
});
|
|
633
|
+
return new Promise((resolve, reject) => {
|
|
634
|
+
const onAbort = () => {
|
|
635
|
+
clearTimeout(timer);
|
|
636
|
+
reject(uploadCancelled());
|
|
637
|
+
};
|
|
638
|
+
const timer = setTimeout(() => {
|
|
639
|
+
context.signal?.removeEventListener("abort", onAbort);
|
|
640
|
+
resolve();
|
|
641
|
+
}, milliseconds);
|
|
642
|
+
context.signal?.addEventListener("abort", onAbort, { once: true });
|
|
643
|
+
});
|
|
644
|
+
}
|
|
645
|
+
async function uploadVideoOrGif(media, context) {
|
|
646
|
+
try {
|
|
647
|
+
return await chunkedUpload(media, context);
|
|
648
|
+
}
|
|
649
|
+
catch (error) {
|
|
650
|
+
// Malformed upload responses fail before any post request, so they are definite failures.
|
|
651
|
+
if (error instanceof HttpError)
|
|
652
|
+
throw mediaHttpError(error);
|
|
653
|
+
throw error;
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
async function chunkedUpload(media, context) {
|
|
657
|
+
requireUserToken("media.upload");
|
|
658
|
+
const category = chunkedCategory(media);
|
|
659
|
+
if (category === undefined || media.source.kind !== "blob")
|
|
660
|
+
throw new SocialError({
|
|
661
|
+
code: "invalid_input",
|
|
662
|
+
operation: "media.upload",
|
|
663
|
+
message: "X chunked upload requires a video/mp4 or image/gif Blob.",
|
|
664
|
+
});
|
|
665
|
+
chunkedLimits(media, category);
|
|
666
|
+
const blob = media.source.blob;
|
|
667
|
+
const totalBytes = blob.size;
|
|
668
|
+
const initialized = await chunkedPost("/2/media/upload/initialize", {
|
|
669
|
+
media_category: category,
|
|
670
|
+
media_type: category === "tweet_video" ? "video/mp4" : "image/gif",
|
|
671
|
+
total_bytes: totalBytes,
|
|
672
|
+
}, context);
|
|
673
|
+
const mediaId = string(initialized["id"]);
|
|
674
|
+
const segmentCount = Math.ceil(totalBytes / xChunkBytes);
|
|
675
|
+
for (let segmentIndex = 0; segmentIndex < segmentCount; segmentIndex++) {
|
|
676
|
+
throwIfUploadCancelled(context);
|
|
677
|
+
const start = segmentIndex * xChunkBytes;
|
|
678
|
+
const end = Math.min(start + xChunkBytes, totalBytes);
|
|
679
|
+
const chunk = blob.slice(start, end, media.mimeType ?? "");
|
|
680
|
+
await appendChunk(mediaId, segmentIndex, chunk, context);
|
|
681
|
+
}
|
|
682
|
+
const finalized = await chunkedPost(`/2/media/upload/${encodeURIComponent(mediaId)}/finalize`, undefined, context);
|
|
683
|
+
const finalizedId = optionalString(finalized["id"]) ?? mediaId;
|
|
684
|
+
const processing = finalized["processing_info"];
|
|
685
|
+
if (processing === undefined)
|
|
686
|
+
return finalizedId;
|
|
687
|
+
let { state, checkAfterSecs } = processingState(object(processing));
|
|
688
|
+
for (let poll = 0;; poll++) {
|
|
689
|
+
if (state === "succeeded")
|
|
690
|
+
return finalizedId;
|
|
691
|
+
if (state === "failed")
|
|
692
|
+
throw new SocialError({
|
|
693
|
+
code: "media_error",
|
|
694
|
+
operation: "media.upload",
|
|
695
|
+
message: "X failed to process the uploaded media. No post was created.",
|
|
696
|
+
retryDisposition: { kind: "never" },
|
|
697
|
+
});
|
|
698
|
+
if (poll >= xMaxStatusPolls)
|
|
699
|
+
break;
|
|
700
|
+
await processingWait(checkAfterSecs * 1000, context);
|
|
701
|
+
({ state, checkAfterSecs } = await readMediaStatus(finalizedId, context));
|
|
702
|
+
}
|
|
703
|
+
throw new SocialError({
|
|
704
|
+
code: "timeout",
|
|
705
|
+
operation: "media.upload",
|
|
706
|
+
message: "X media processing did not complete in time. Reconcile before retrying.",
|
|
707
|
+
retryDisposition: { kind: "never" },
|
|
708
|
+
});
|
|
709
|
+
}
|
|
710
|
+
async function uploadXMedia(media, context) {
|
|
711
|
+
if (chunkedCategory(media) !== undefined)
|
|
712
|
+
return uploadVideoOrGif(media, context);
|
|
713
|
+
return uploadImage(media, context);
|
|
714
|
+
}
|
|
399
715
|
async function createPost(account, text, context, extra = {}, targetIndex = 0) {
|
|
400
716
|
authorize(account, context);
|
|
401
717
|
const result = object(await request("/2/tweets", context, { text, ...extra }));
|
|
@@ -431,13 +747,14 @@ export function x(options) {
|
|
|
431
747
|
apiRevision: "X API v2 / OpenAPI 2.168",
|
|
432
748
|
runtime: ["node22", "node24", "bun"],
|
|
433
749
|
capabilities: [
|
|
750
|
+
webhookCapability("x", "Verifies X-Twitter-Webhooks-Signature-OAuth2 or the legacy X-Twitter-Webhooks-Signature and decodes Account Activity deliveries. Answer the CRC GET with answerXWebhookChallenge."),
|
|
434
751
|
{
|
|
435
752
|
platform: "x",
|
|
436
753
|
operation: "posts.publish",
|
|
437
754
|
availability: "available",
|
|
438
|
-
formats: ["text", "image"],
|
|
755
|
+
formats: ["text", "image", "video"],
|
|
439
756
|
requiredScopes: ["tweet.read", "tweet.write", "users.read", "media.write"],
|
|
440
|
-
notes: "User-context OAuth2 token; current X API access/billing required. Up to four static JPEG/PNG image Blobs, each at most 5 MiB
|
|
757
|
+
notes: "User-context OAuth2 token; current X API access/billing required. Up to four static JPEG/PNG image Blobs, each at most 5 MiB, or one MP4 video up to 512 MiB / one GIF up to 15 MiB via 1 MiB chunked upload with bounded processing poll. Post attach can still reject over-duration video with 403.",
|
|
441
758
|
},
|
|
442
759
|
...[
|
|
443
760
|
"accounts.read",
|
|
@@ -483,6 +800,14 @@ export function x(options) {
|
|
|
483
800
|
availability: "available",
|
|
484
801
|
requiredScopes: ["tweet.read", "tweet.write", "users.read"],
|
|
485
802
|
},
|
|
803
|
+
{
|
|
804
|
+
platform: "x",
|
|
805
|
+
operation: "posts.update",
|
|
806
|
+
availability: "available",
|
|
807
|
+
formats: ["text"],
|
|
808
|
+
requiredScopes: ["tweet.read", "tweet.write", "users.read"],
|
|
809
|
+
notes: "Text-only edit through native.updatePost. X requires X Premium, the account's own post, and a recent post within X's edit window and edit count. Polls, replies to others, reposts, and scheduled posts are not editable. Each edit creates a new post ID.",
|
|
810
|
+
},
|
|
486
811
|
{
|
|
487
812
|
platform: "x",
|
|
488
813
|
operation: "graph.read",
|
|
@@ -560,6 +885,13 @@ export function x(options) {
|
|
|
560
885
|
availability: "available",
|
|
561
886
|
requiredScopes: ["dm.write"],
|
|
562
887
|
},
|
|
888
|
+
{
|
|
889
|
+
platform: "x",
|
|
890
|
+
operation: "comments.read",
|
|
891
|
+
availability: "available",
|
|
892
|
+
requiredScopes: ["tweet.read", "users.read"],
|
|
893
|
+
notes: "Replies come from recent search with conversation_id, so only replies from the last 7 days are returned. Pass the conversation's root post. Page limits are 10-100. Recent search allows 450 requests per 15 minutes per app and 300 per user, and X bills post reads under the app's plan.",
|
|
894
|
+
},
|
|
563
895
|
{
|
|
564
896
|
platform: "x",
|
|
565
897
|
operation: "search.posts",
|
|
@@ -570,13 +902,17 @@ export function x(options) {
|
|
|
570
902
|
{
|
|
571
903
|
platform: "x",
|
|
572
904
|
operation: "media.video",
|
|
573
|
-
availability: "
|
|
905
|
+
availability: "available",
|
|
574
906
|
formats: ["video"],
|
|
907
|
+
requiredScopes: ["tweet.read", "tweet.write", "users.read", "media.write"],
|
|
908
|
+
notes: "MP4 Blob chunked INIT/APPEND/FINALIZE with 1 MiB segments and bounded STATUS poll honoring check_after_secs.",
|
|
575
909
|
},
|
|
576
910
|
{
|
|
577
911
|
platform: "x",
|
|
578
912
|
operation: "media.gif",
|
|
579
|
-
availability: "
|
|
913
|
+
availability: "available",
|
|
914
|
+
requiredScopes: ["tweet.read", "tweet.write", "users.read", "media.write"],
|
|
915
|
+
notes: "GIF Blob chunked upload; large GIFs process asynchronously before attach.",
|
|
580
916
|
},
|
|
581
917
|
{
|
|
582
918
|
platform: "x",
|
|
@@ -585,6 +921,12 @@ export function x(options) {
|
|
|
585
921
|
requiredScopes: ["dm.read", "users.read", "tweet.read"],
|
|
586
922
|
notes: "Requires a user-context token and an X API tier that includes Direct Messages.",
|
|
587
923
|
},
|
|
924
|
+
{
|
|
925
|
+
platform: "x",
|
|
926
|
+
operation: "notifications.read",
|
|
927
|
+
availability: "unsupported-by-platform",
|
|
928
|
+
notes: "X API v2 has no notifications list endpoint. Use mentions.read, or the Account Activity or X Activity API webhooks and streams.",
|
|
929
|
+
},
|
|
588
930
|
{
|
|
589
931
|
platform: "x",
|
|
590
932
|
operation: "messages.write",
|
|
@@ -592,11 +934,30 @@ export function x(options) {
|
|
|
592
934
|
requiredScopes: ["dm.write", "dm.read", "users.read", "tweet.read"],
|
|
593
935
|
notes: "Requires a user-context token and an X API tier that includes Direct Messages.",
|
|
594
936
|
},
|
|
937
|
+
{
|
|
938
|
+
platform: "x",
|
|
939
|
+
operation: "comments.moderate",
|
|
940
|
+
availability: "available",
|
|
941
|
+
requiredScopes: ["tweet.moderate.write", "tweet.read", "users.read"],
|
|
942
|
+
notes: "Native hideReply hides or unhides replies in conversations the authenticated user started. Requires a user-context token.",
|
|
943
|
+
},
|
|
595
944
|
{
|
|
596
945
|
platform: "x",
|
|
597
946
|
operation: "streams.read",
|
|
947
|
+
availability: "available",
|
|
948
|
+
notes: "Filtered stream via native stream, listStreamRules, addStreamRules, and deleteStreamRules with the app-only appBearerToken. Needs X API pay-per-use (1 connection, 1,000 rules of up to 1,024 characters) or Enterprise (multiple connections, 25,000+ rules of up to 2,048 characters). backfillMinutes and startTime/endTime recovery need Enterprise. One caller-controlled connection per iteration; no automatic reconnect.",
|
|
949
|
+
},
|
|
950
|
+
{
|
|
951
|
+
platform: "x",
|
|
952
|
+
operation: "posts.schedule",
|
|
598
953
|
availability: "not-implemented-by-adapter",
|
|
599
|
-
notes: "
|
|
954
|
+
notes: "X API v2 has no scheduled-post field; POST /2/tweets publishes immediately. X Ads API scheduled Tweets (ads-api.x.com/12/accounts/:account_id/scheduled_tweets) need Ads API approval, an ads account, and OAuth 1.0a-signed requests, which this OAuth 2.0 adapter does not implement (https://docs.x.com/x-ads-api/fundamentals/making-authenticated-requests, checked 2026-09-24). Scheduled Tweets default to nullcast=true (promoted-only, not on the public timeline); organic nullcast=false Tweets can only be created by the ads account's full promotable user (https://docs.x.com/x-ads-api/creatives, checked 2026-09-24). Use an application-owned job runner to publish at a chosen time.",
|
|
955
|
+
},
|
|
956
|
+
{
|
|
957
|
+
platform: "x",
|
|
958
|
+
operation: "profile.update",
|
|
959
|
+
availability: "unsupported-by-platform",
|
|
960
|
+
notes: "X API v2 (OpenAPI 2.168, https://docs.x.com/openapi.json, checked 2026-09-24) has no endpoint that writes the user's profile. The v1.1 POST account/update_profile reference is no longer published on docs.x.com (its developer.x.com URL redirects to https://docs.x.com/overview), and v1.1 user writes require OAuth 1.0a.",
|
|
600
961
|
},
|
|
601
962
|
],
|
|
602
963
|
},
|
|
@@ -625,16 +986,17 @@ export function x(options) {
|
|
|
625
986
|
accountId: account.accountId,
|
|
626
987
|
profileId: id,
|
|
627
988
|
}),
|
|
628
|
-
...(
|
|
629
|
-
|
|
630
|
-
|
|
989
|
+
...definedFields({
|
|
990
|
+
displayName: optionalString(user["name"]),
|
|
991
|
+
handle: optionalString(user["username"]),
|
|
992
|
+
bio: optionalString(user["description"]),
|
|
993
|
+
}),
|
|
631
994
|
native: user,
|
|
632
995
|
};
|
|
633
996
|
},
|
|
634
997
|
async listRelationships(account, input, context) {
|
|
635
998
|
const pagination = {
|
|
636
|
-
...(
|
|
637
|
-
...(input.limit === undefined ? {} : { limit: input.limit }),
|
|
999
|
+
...definedFields({ cursor: input.cursor, limit: input.limit }),
|
|
638
1000
|
};
|
|
639
1001
|
const result = input.kind === "following"
|
|
640
1002
|
? await nativeAdapter.following({
|
|
@@ -679,7 +1041,7 @@ export function x(options) {
|
|
|
679
1041
|
: input.kind,
|
|
680
1042
|
};
|
|
681
1043
|
}),
|
|
682
|
-
...(
|
|
1044
|
+
...definedFields({ nextCursor: result.nextCursor }),
|
|
683
1045
|
};
|
|
684
1046
|
},
|
|
685
1047
|
async follow(target, context) {
|
|
@@ -748,23 +1110,41 @@ export function x(options) {
|
|
|
748
1110
|
if (text && !isValidXText(text))
|
|
749
1111
|
fail("x.text", "Text exceeds X's weighted 280-character limit or contains invalid characters.");
|
|
750
1112
|
if (target.schedule || target.content.link)
|
|
751
|
-
fail("x.operation", "
|
|
1113
|
+
fail("x.operation", "X API v2 cannot schedule posts; use an application job runner. Place URLs explicitly in text.");
|
|
752
1114
|
if (target.replyTo &&
|
|
753
1115
|
(target.replyTo.platform !== "x" ||
|
|
754
1116
|
target.replyTo.backend !== target.account.backend ||
|
|
755
1117
|
target.replyTo.accountId !== target.account.accountId ||
|
|
756
1118
|
target.replyTo.kind !== "platform-post"))
|
|
757
1119
|
fail("x.reply", "Use a platform-post reply reference authorized for this account and backend.");
|
|
758
|
-
const settings =
|
|
1120
|
+
const settings = optionsObject(target);
|
|
759
1121
|
if (Object.keys(settings).some((key) => key !== "replySettings") ||
|
|
760
1122
|
(settings["replySettings"] !== undefined &&
|
|
761
1123
|
!["everyone", "following", "mentionedUsers"].includes(String(settings["replySettings"]))))
|
|
762
1124
|
fail("x.options", "Provide only a supported replySettings value.");
|
|
763
1125
|
const media = target.content.media ?? [];
|
|
764
|
-
|
|
1126
|
+
const hasChunked = media.some((item) => chunkedCategory(item) !== undefined);
|
|
1127
|
+
if (hasChunked && media.length !== 1)
|
|
1128
|
+
fail("x.media_count", "Attach a single video or GIF per post.");
|
|
1129
|
+
else if (!hasChunked && media.length > 4)
|
|
765
1130
|
fail("x.media_count", "Attach up to four images.");
|
|
766
1131
|
for (const item of media) {
|
|
767
|
-
|
|
1132
|
+
const category = chunkedCategory(item);
|
|
1133
|
+
if (category === "tweet_video") {
|
|
1134
|
+
if (item.source.kind !== "blob" || item.source.blob.size === 0)
|
|
1135
|
+
fail("x.video_size", "Video must be a non-empty MP4 Blob.");
|
|
1136
|
+
else if (item.source.blob.size > xMaxVideoBytes)
|
|
1137
|
+
fail("x.video_size", "Video exceeds the 512 MiB limit.");
|
|
1138
|
+
}
|
|
1139
|
+
else if (category === "tweet_gif") {
|
|
1140
|
+
if (item.source.kind !== "blob" || item.source.blob.size === 0)
|
|
1141
|
+
fail("x.gif_size", "GIF must be a non-empty Blob.");
|
|
1142
|
+
else if (item.source.blob.size > xMaxGifBytes)
|
|
1143
|
+
fail("x.gif_size", "GIF exceeds the 15 MiB limit.");
|
|
1144
|
+
}
|
|
1145
|
+
else if (item.kind === "video")
|
|
1146
|
+
fail("x.video", "X video uploads require a video/mp4 Blob.");
|
|
1147
|
+
else if (item.kind !== "image" ||
|
|
768
1148
|
item.source.kind !== "blob" ||
|
|
769
1149
|
!["image/jpeg", "image/png"].includes(item.mimeType ?? ""))
|
|
770
1150
|
fail("x.image", "This slice requires a JPEG or PNG image Blob.");
|
|
@@ -779,18 +1159,33 @@ export function x(options) {
|
|
|
779
1159
|
authorize(target.account, context);
|
|
780
1160
|
const ids = [];
|
|
781
1161
|
for (const media of target.content.media ?? [])
|
|
782
|
-
ids.push(await
|
|
783
|
-
const
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
}
|
|
1162
|
+
ids.push(await uploadXMedia(media, context));
|
|
1163
|
+
const replySettings = optionsObject(target)["replySettings"];
|
|
1164
|
+
const hasVideo = (target.content.media ?? []).some((media) => chunkedCategory(media) === "tweet_video");
|
|
1165
|
+
try {
|
|
1166
|
+
return await createPost(target.account, target.content.text ?? "", context, {
|
|
1167
|
+
...definedFields({
|
|
1168
|
+
media: ids.length ? { media_ids: ids } : undefined,
|
|
1169
|
+
reply: target.replyTo ? { in_reply_to_tweet_id: target.replyTo.postId } : undefined,
|
|
1170
|
+
reply_settings: replySettings && replySettings !== "everyone" ? string(replySettings) : undefined,
|
|
1171
|
+
}),
|
|
1172
|
+
}, target.targetIndex);
|
|
1173
|
+
}
|
|
1174
|
+
catch (error) {
|
|
1175
|
+
// X checks video duration only at attach time and answers 403. The transport drops the
|
|
1176
|
+
// response body, so the adapter cannot tell a duration limit from a missing permission.
|
|
1177
|
+
if (hasVideo && error instanceof SocialError && error.upstreamStatus === 403)
|
|
1178
|
+
throw new SocialError({
|
|
1179
|
+
code: "missing_permission",
|
|
1180
|
+
operation: error.operation,
|
|
1181
|
+
backend: error.backend,
|
|
1182
|
+
correlationId: error.correlationId,
|
|
1183
|
+
message: "X rejected the post with its attached video (HTTP 403). The video may exceed this account's duration limit, or the token may lack post permission. No post was created.",
|
|
1184
|
+
upstreamStatus: 403,
|
|
1185
|
+
retryDisposition: { kind: "never" },
|
|
1186
|
+
});
|
|
1187
|
+
throw error;
|
|
1188
|
+
}
|
|
794
1189
|
},
|
|
795
1190
|
async get(ref, context) {
|
|
796
1191
|
return publicFields(await readPost(ref, context), [
|
|
@@ -815,12 +1210,8 @@ export function x(options) {
|
|
|
815
1210
|
},
|
|
816
1211
|
},
|
|
817
1212
|
comments: {
|
|
818
|
-
async list() {
|
|
819
|
-
|
|
820
|
-
code: "unsupported_capability",
|
|
821
|
-
operation: "comments.read",
|
|
822
|
-
message: "X reply search is not implemented in this slice.",
|
|
823
|
-
});
|
|
1213
|
+
async list(ref, input, context) {
|
|
1214
|
+
return listReplies(ref, input, context);
|
|
824
1215
|
},
|
|
825
1216
|
async reply(ref, content, context) {
|
|
826
1217
|
await validateReplyParent(ref, context);
|
|
@@ -861,7 +1252,10 @@ export function x(options) {
|
|
|
861
1252
|
const seen = [];
|
|
862
1253
|
if (input.cursor !== undefined) {
|
|
863
1254
|
try {
|
|
864
|
-
const
|
|
1255
|
+
const parsed = JSON.parse(input.cursor);
|
|
1256
|
+
if (!isJsonValue(parsed))
|
|
1257
|
+
throw new Error("bad cursor");
|
|
1258
|
+
const state = object(parsed);
|
|
865
1259
|
eventCursor = optionalString(state["c"]);
|
|
866
1260
|
const hashes = string(state["s"]);
|
|
867
1261
|
if (hashes.length % conversationHashWidth !== 0)
|
|
@@ -887,8 +1281,7 @@ export function x(options) {
|
|
|
887
1281
|
for (let fetches = 0; fetches < 10; fetches++) {
|
|
888
1282
|
const page = await nativeAdapter.listDirectMessages({
|
|
889
1283
|
account,
|
|
890
|
-
...(
|
|
891
|
-
...(input.limit === undefined ? {} : { limit: input.limit }),
|
|
1284
|
+
...definedFields({ cursor: eventCursor, limit: input.limit }),
|
|
892
1285
|
context,
|
|
893
1286
|
});
|
|
894
1287
|
for (const event of page.items) {
|
|
@@ -924,8 +1317,7 @@ export function x(options) {
|
|
|
924
1317
|
accountId: conversation.accountId,
|
|
925
1318
|
}),
|
|
926
1319
|
conversationId: conversation.conversationId,
|
|
927
|
-
...(
|
|
928
|
-
...(input.limit === undefined ? {} : { limit: input.limit }),
|
|
1320
|
+
...definedFields({ cursor: input.cursor, limit: input.limit }),
|
|
929
1321
|
context,
|
|
930
1322
|
});
|
|
931
1323
|
},
|
|
@@ -979,6 +1371,7 @@ export function x(options) {
|
|
|
979
1371
|
});
|
|
980
1372
|
},
|
|
981
1373
|
},
|
|
1374
|
+
webhooks: directWebhooks("x", (input) => verifyXWebhook({ ...input, secret: options.webhookSecret ?? "" }), now),
|
|
982
1375
|
native: (nativeAdapter = {
|
|
983
1376
|
async searchRecentPosts({ account, search, context }) {
|
|
984
1377
|
return searchPosts(account, { ...search, scope: "recent" }, context, search);
|
|
@@ -989,20 +1382,83 @@ export function x(options) {
|
|
|
989
1382
|
readPost,
|
|
990
1383
|
async repost({ account, postId, context }) {
|
|
991
1384
|
authorize(account, context);
|
|
992
|
-
// oxlint-disable-next-line anti-slop/require-safety-comment-for-type-assertion -- validated boundary or fixture contract.
|
|
993
1385
|
return object(await request(`/2/users/${encodeURIComponent(account.accountId)}/retweets`, context, {
|
|
994
1386
|
tweet_id: postId,
|
|
995
1387
|
}));
|
|
996
1388
|
},
|
|
997
1389
|
async quote({ account, text, quotedPostId, context }) {
|
|
998
1390
|
authorize(account, context);
|
|
999
|
-
// oxlint-disable-next-line anti-slop/require-safety-comment-for-type-assertion -- validated boundary or fixture contract.
|
|
1000
1391
|
return object(await request("/2/tweets", context, { text, quote_tweet_id: quotedPostId }));
|
|
1001
1392
|
},
|
|
1002
1393
|
async deletePost({ account, postId, context }) {
|
|
1003
1394
|
authorize(account, context);
|
|
1004
1395
|
await request(`/2/tweets/${encodeURIComponent(postId)}`, context, undefined, {}, "DELETE");
|
|
1005
1396
|
},
|
|
1397
|
+
async updatePost({ account, postId, text, context }) {
|
|
1398
|
+
authorize(account, context);
|
|
1399
|
+
if (!/^[0-9]{1,19}$/.test(postId))
|
|
1400
|
+
throw new SocialError({
|
|
1401
|
+
code: "invalid_input",
|
|
1402
|
+
operation: "posts.update",
|
|
1403
|
+
message: "X post edits require a numeric post ID.",
|
|
1404
|
+
});
|
|
1405
|
+
if (!text || !isValidXText(text))
|
|
1406
|
+
throw new SocialError({
|
|
1407
|
+
code: "invalid_input",
|
|
1408
|
+
operation: "posts.update",
|
|
1409
|
+
message: "Edited text is empty, exceeds X's weighted 280-character limit, or contains invalid characters.",
|
|
1410
|
+
});
|
|
1411
|
+
const result = object(await request("/2/tweets", context, {
|
|
1412
|
+
text,
|
|
1413
|
+
edit_options: { previous_post_id: postId },
|
|
1414
|
+
}));
|
|
1415
|
+
const data = result["data"] === undefined ? {} : object(result["data"]);
|
|
1416
|
+
const id = optionalString(data["id"]);
|
|
1417
|
+
if (!id)
|
|
1418
|
+
throw new SocialError({
|
|
1419
|
+
code: "ambiguous_outcome",
|
|
1420
|
+
operation: "posts.update",
|
|
1421
|
+
backend: context.backendInstance,
|
|
1422
|
+
correlationId: context.correlationId,
|
|
1423
|
+
message: "X edit response lacks a new post ID. Reconcile before retrying.",
|
|
1424
|
+
retryDisposition: { kind: "reconcile-first" },
|
|
1425
|
+
});
|
|
1426
|
+
const history = data["edit_history_post_ids"] ?? data["edit_history_tweet_ids"];
|
|
1427
|
+
const editHistoryPostIds = Array.isArray(history)
|
|
1428
|
+
? history.filter((entry) => typeof entry === "string")
|
|
1429
|
+
: undefined;
|
|
1430
|
+
return {
|
|
1431
|
+
post: {
|
|
1432
|
+
kind: "platform-post",
|
|
1433
|
+
version: 1,
|
|
1434
|
+
backend: account.backend,
|
|
1435
|
+
platform: "x",
|
|
1436
|
+
accountId: account.accountId,
|
|
1437
|
+
postId: id,
|
|
1438
|
+
},
|
|
1439
|
+
previousPostId: postId,
|
|
1440
|
+
text: optionalString(data["text"]) ?? text,
|
|
1441
|
+
...definedFields({ editHistoryPostIds }),
|
|
1442
|
+
};
|
|
1443
|
+
},
|
|
1444
|
+
async uploadVideo({ account, video, context }) {
|
|
1445
|
+
authorize(account, context);
|
|
1446
|
+
const media = {
|
|
1447
|
+
kind: "video",
|
|
1448
|
+
mimeType: "video/mp4",
|
|
1449
|
+
source: { kind: "blob", blob: video, fingerprint: "native-upload" },
|
|
1450
|
+
};
|
|
1451
|
+
return { mediaId: await uploadVideoOrGif(media, context) };
|
|
1452
|
+
},
|
|
1453
|
+
async uploadGif({ account, gif, context }) {
|
|
1454
|
+
authorize(account, context);
|
|
1455
|
+
const media = {
|
|
1456
|
+
kind: "image",
|
|
1457
|
+
mimeType: "image/gif",
|
|
1458
|
+
source: { kind: "blob", blob: gif, fingerprint: "native-upload" },
|
|
1459
|
+
};
|
|
1460
|
+
return { mediaId: await uploadVideoOrGif(media, context) };
|
|
1461
|
+
},
|
|
1006
1462
|
async createPoll({ account, text, options: pollOptions, durationMinutes, context }) {
|
|
1007
1463
|
authorize(account, context);
|
|
1008
1464
|
if (pollOptions.length < 2 ||
|
|
@@ -1014,7 +1470,6 @@ export function x(options) {
|
|
|
1014
1470
|
operation: "x.polls.create",
|
|
1015
1471
|
message: "X polls require 2-4 options and a duration from 5 minutes to 7 days.",
|
|
1016
1472
|
});
|
|
1017
|
-
// oxlint-disable-next-line anti-slop/require-safety-comment-for-type-assertion -- validated boundary or fixture contract.
|
|
1018
1473
|
return object(await request("/2/tweets", context, {
|
|
1019
1474
|
text,
|
|
1020
1475
|
poll: { options: [...pollOptions], duration_minutes: durationMinutes },
|
|
@@ -1022,7 +1477,6 @@ export function x(options) {
|
|
|
1022
1477
|
},
|
|
1023
1478
|
async bookmarks({ account, context }) {
|
|
1024
1479
|
authorize(account, context);
|
|
1025
|
-
// oxlint-disable-next-line anti-slop/require-safety-comment-for-type-assertion -- validated boundary or fixture contract.
|
|
1026
1480
|
return object(await request(`/2/users/${encodeURIComponent(account.accountId)}/bookmarks`, context));
|
|
1027
1481
|
},
|
|
1028
1482
|
async bookmark({ account, postId, context }) {
|
|
@@ -1037,7 +1491,6 @@ export function x(options) {
|
|
|
1037
1491
|
},
|
|
1038
1492
|
async follow({ account, userId, context }) {
|
|
1039
1493
|
authorize(account, context);
|
|
1040
|
-
// oxlint-disable-next-line anti-slop/require-safety-comment-for-type-assertion -- validated boundary or fixture contract.
|
|
1041
1494
|
return object(await request(`/2/users/${encodeURIComponent(account.accountId)}/following`, context, {
|
|
1042
1495
|
target_user_id: userId,
|
|
1043
1496
|
}));
|
|
@@ -1062,14 +1515,13 @@ export function x(options) {
|
|
|
1062
1515
|
const result = object(await request(path, context, undefined, {
|
|
1063
1516
|
"dm_event.fields": "id,text,event_type,created_at,dm_conversation_id,attachments,entities",
|
|
1064
1517
|
expansions: "sender_id,participant_ids",
|
|
1065
|
-
...(
|
|
1066
|
-
...(limit === undefined ? {} : { max_results: String(limit) }),
|
|
1518
|
+
...definedFields({ pagination_token: cursor, max_results: limit?.toString() }),
|
|
1067
1519
|
}));
|
|
1068
1520
|
const meta = result["meta"] === undefined ? {} : object(result["meta"]);
|
|
1069
1521
|
const nextCursor = optionalString(meta["next_token"]);
|
|
1070
1522
|
return {
|
|
1071
1523
|
items: result["data"] === undefined ? [] : array(result["data"]).map(object),
|
|
1072
|
-
...(
|
|
1524
|
+
...definedFields({ nextCursor }),
|
|
1073
1525
|
};
|
|
1074
1526
|
},
|
|
1075
1527
|
async sendDirectMessage({ account, participantId, text, context }) {
|
|
@@ -1091,7 +1543,7 @@ export function x(options) {
|
|
|
1091
1543
|
operation: "messages.conversation.write",
|
|
1092
1544
|
message: "X direct messages require non-empty text.",
|
|
1093
1545
|
});
|
|
1094
|
-
return object(await requireUserToken("messages.conversation.write")(`/2/dm_conversations/${encodeURIComponent(conversationId)}/messages`, context, { text, ...(
|
|
1546
|
+
return object(await requireUserToken("messages.conversation.write")(`/2/dm_conversations/${encodeURIComponent(conversationId)}/messages`, context, { text, ...definedFields({ attachments }) }));
|
|
1095
1547
|
},
|
|
1096
1548
|
async createGroupConversation({ account, participantIds, message, context }) {
|
|
1097
1549
|
authorize(account, context);
|
|
@@ -1206,16 +1658,13 @@ export function x(options) {
|
|
|
1206
1658
|
authorize(account, context);
|
|
1207
1659
|
return object(await request("/2/lists", context, {
|
|
1208
1660
|
name,
|
|
1209
|
-
...(description
|
|
1210
|
-
...(isPrivate === undefined ? {} : { private: isPrivate }),
|
|
1661
|
+
...definedFields({ description, private: isPrivate }),
|
|
1211
1662
|
}));
|
|
1212
1663
|
},
|
|
1213
1664
|
async updateList({ account, listId, name, description, isPrivate, context }) {
|
|
1214
1665
|
authorize(account, context);
|
|
1215
1666
|
return object(await request(`/2/lists/${encodeURIComponent(listId)}`, context, {
|
|
1216
|
-
...(name
|
|
1217
|
-
...(description === undefined ? {} : { description }),
|
|
1218
|
-
...(isPrivate === undefined ? {} : { private: isPrivate }),
|
|
1667
|
+
...definedFields({ name, description, private: isPrivate }),
|
|
1219
1668
|
}, {}, "PUT"));
|
|
1220
1669
|
},
|
|
1221
1670
|
async deleteList({ account, listId, context }) {
|
|
@@ -1281,6 +1730,85 @@ export function x(options) {
|
|
|
1281
1730
|
authorize(account, context);
|
|
1282
1731
|
await request(`/2/users/${encodeURIComponent(account.accountId)}/retweets/${encodeURIComponent(postId)}`, context, undefined, {}, "DELETE");
|
|
1283
1732
|
},
|
|
1733
|
+
async listStreamRules({ account, ids, cursor, limit, context }) {
|
|
1734
|
+
authorize(account, context);
|
|
1735
|
+
if (ids !== undefined)
|
|
1736
|
+
validateRuleIds(ids, "x.streamRules.list", 1000);
|
|
1737
|
+
if (limit !== undefined && (!Number.isSafeInteger(limit) || limit < 1 || limit > 1000))
|
|
1738
|
+
throw new SocialError({
|
|
1739
|
+
code: "invalid_input",
|
|
1740
|
+
operation: "x.streamRules.list",
|
|
1741
|
+
message: "X stream rule limits must be integers from 1 through 1000.",
|
|
1742
|
+
});
|
|
1743
|
+
const result = object(await appRequest()("/2/tweets/search/stream/rules", context, undefined, {
|
|
1744
|
+
...definedFields({
|
|
1745
|
+
ids: ids?.join(","),
|
|
1746
|
+
pagination_token: cursor,
|
|
1747
|
+
max_results: limit === undefined ? undefined : String(limit),
|
|
1748
|
+
}),
|
|
1749
|
+
}));
|
|
1750
|
+
const items = (result["data"] === undefined ? [] : array(result["data"])).map((entry) => parseStreamRule(object(entry), "x.streamRules.list"));
|
|
1751
|
+
const meta = result["meta"] === undefined ? {} : object(result["meta"]);
|
|
1752
|
+
const nextCursor = optionalString(meta["next_token"]);
|
|
1753
|
+
return { items, ...definedFields({ nextCursor }) };
|
|
1754
|
+
},
|
|
1755
|
+
async addStreamRules({ account, rules, dryRun = false, context }) {
|
|
1756
|
+
authorize(account, context);
|
|
1757
|
+
if (rules.length === 0)
|
|
1758
|
+
throw new SocialError({
|
|
1759
|
+
code: "invalid_input",
|
|
1760
|
+
operation: "x.streamRules.add",
|
|
1761
|
+
message: "Provide at least one X filtered-stream rule to add.",
|
|
1762
|
+
});
|
|
1763
|
+
for (const rule of rules)
|
|
1764
|
+
validateRuleInput(rule);
|
|
1765
|
+
const result = await appRequest()("/2/tweets/search/stream/rules", context, {
|
|
1766
|
+
add: rules.map((rule) => ({
|
|
1767
|
+
value: rule.value,
|
|
1768
|
+
...definedFields({ tag: rule.tag }),
|
|
1769
|
+
})),
|
|
1770
|
+
}, dryRun ? { dry_run: "true" } : {});
|
|
1771
|
+
return parseRulesUpdate(object(result), dryRun);
|
|
1772
|
+
},
|
|
1773
|
+
async deleteStreamRules({ account, ids, dryRun = false, context }) {
|
|
1774
|
+
authorize(account, context);
|
|
1775
|
+
validateRuleIds(ids, "x.streamRules.delete", 1000);
|
|
1776
|
+
const result = await appRequest()("/2/tweets/search/stream/rules", context, { delete: { ids: [...ids] } }, dryRun ? { dry_run: "true" } : {});
|
|
1777
|
+
return parseRulesUpdate(object(result), dryRun);
|
|
1778
|
+
},
|
|
1779
|
+
async *stream({ account, context, ...streamOptions }) {
|
|
1780
|
+
authorize(account, context);
|
|
1781
|
+
const bearerToken = options.appBearerToken;
|
|
1782
|
+
if (!bearerToken?.trim())
|
|
1783
|
+
throw new SocialError({
|
|
1784
|
+
code: "missing_permission",
|
|
1785
|
+
operation: "streams.read",
|
|
1786
|
+
message: "The X filtered stream requires an app-only bearer token. Configure appBearerToken.",
|
|
1787
|
+
});
|
|
1788
|
+
yield* readFilteredStream({ bearerToken, fetch: options.fetch }, streamOptions, context);
|
|
1789
|
+
},
|
|
1790
|
+
async hideReply({ account, replyId, hidden, context }) {
|
|
1791
|
+
authorize(account, context);
|
|
1792
|
+
if (!/^[0-9]{1,19}$/.test(replyId))
|
|
1793
|
+
throw new SocialError({
|
|
1794
|
+
code: "invalid_input",
|
|
1795
|
+
operation: "comments.moderate",
|
|
1796
|
+
message: "X reply IDs are numeric strings of 1 to 19 digits.",
|
|
1797
|
+
retryDisposition: { kind: "never" },
|
|
1798
|
+
});
|
|
1799
|
+
const result = object(await requireUserToken("comments.moderate")(`/2/tweets/${encodeURIComponent(replyId)}/hidden`, context, { hidden }, {}, "PUT"));
|
|
1800
|
+
const state = result["data"] === undefined
|
|
1801
|
+
? undefined
|
|
1802
|
+
: optionalBoolean(object(result["data"])["hidden"]);
|
|
1803
|
+
if (state === undefined)
|
|
1804
|
+
throw new SocialError({
|
|
1805
|
+
code: "ambiguous_outcome",
|
|
1806
|
+
operation: "comments.moderate",
|
|
1807
|
+
message: "X did not report the reply's hidden state.",
|
|
1808
|
+
retryDisposition: { kind: "reconcile-first" },
|
|
1809
|
+
});
|
|
1810
|
+
return { hidden: state };
|
|
1811
|
+
},
|
|
1284
1812
|
}),
|
|
1285
1813
|
});
|
|
1286
1814
|
return adapter;
|