@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.
Files changed (63) hide show
  1. package/dist/cli-request.d.ts +15 -0
  2. package/dist/cli-request.js +193 -0
  3. package/dist/cli.d.ts +4 -3
  4. package/dist/cli.js +19 -21
  5. package/dist/cloud/common.d.ts +7 -6
  6. package/dist/cloud/common.js +35 -54
  7. package/dist/cloud/lifecycle.js +31 -35
  8. package/dist/cloud/media.d.ts +2 -2
  9. package/dist/cloud/media.js +13 -3
  10. package/dist/cloud/outcomes.d.ts +4 -3
  11. package/dist/cloud/outcomes.js +8 -15
  12. package/dist/cloud/post-for-me.js +41 -49
  13. package/dist/cloud/zernio.js +58 -98
  14. package/dist/core/client.js +79 -99
  15. package/dist/core/fields.d.ts +14 -0
  16. package/dist/core/fields.js +14 -0
  17. package/dist/core/idempotency.d.ts +7 -2
  18. package/dist/core/idempotency.js +37 -20
  19. package/dist/core/pagination.js +8 -7
  20. package/dist/core/types.d.ts +3 -2
  21. package/dist/platforms/bluesky.d.ts +65 -1
  22. package/dist/platforms/bluesky.js +675 -276
  23. package/dist/platforms/instagram.d.ts +2 -0
  24. package/dist/platforms/instagram.js +130 -105
  25. package/dist/platforms/linkedin.d.ts +58 -1
  26. package/dist/platforms/linkedin.js +877 -107
  27. package/dist/platforms/threads.d.ts +13 -1
  28. package/dist/platforms/threads.js +204 -302
  29. package/dist/platforms/tiktok.d.ts +4 -0
  30. package/dist/platforms/tiktok.js +140 -124
  31. package/dist/platforms/webhook-adapter.d.ts +9 -0
  32. package/dist/platforms/webhook-adapter.js +24 -0
  33. package/dist/platforms/x-engagement.js +7 -12
  34. package/dist/platforms/x-stream.d.ts +83 -0
  35. package/dist/platforms/x-stream.js +350 -0
  36. package/dist/platforms/x.d.ts +87 -0
  37. package/dist/platforms/x.js +648 -120
  38. package/dist/platforms/youtube-upload.d.ts +1 -1
  39. package/dist/platforms/youtube-upload.js +6 -2
  40. package/dist/platforms/youtube.d.ts +28 -4
  41. package/dist/platforms/youtube.js +291 -133
  42. package/dist/server/bluesky-oauth.d.ts +177 -0
  43. package/dist/server/bluesky-oauth.js +1229 -0
  44. package/dist/server/connections.d.ts +14 -0
  45. package/dist/server/connections.js +10 -2
  46. package/dist/server/egress.d.ts +14 -0
  47. package/dist/server/egress.js +115 -0
  48. package/dist/server/oauth-internal.d.ts +6 -0
  49. package/dist/server/oauth-internal.js +66 -0
  50. package/dist/server/oauth.d.ts +1 -1
  51. package/dist/server/oauth.js +46 -99
  52. package/dist/server/webhooks.d.ts +136 -3
  53. package/dist/server/webhooks.js +639 -25
  54. package/dist/testing/index.js +14 -28
  55. package/dist/transport/http.d.ts +1 -1
  56. package/dist/transport/http.js +0 -1
  57. package/dist/transport/json.d.ts +7 -0
  58. package/dist/transport/json.js +32 -4
  59. package/dist/transport/upload.d.ts +1 -1
  60. package/dist/transport/upload.js +46 -38
  61. package/dist/transport/validation.d.ts +16 -5
  62. package/dist/transport/validation.js +29 -7
  63. package/package.json +2 -2
@@ -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 { array, object as parseObject, optionalNumber, optionalString, string, } from "../transport/validation.js";
10
- // oxlint-disable-next-line anti-slop/no-unknown-parameters, anti-slop/require-safety-comment-for-type-assertion -- transport parser validates the provider boundary.
11
- const object = (value) => parseObject(value);
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
- // oxlint-disable-next-line anti-slop/no-conditional-empty-object-spread -- validated boundary or fixture contract.
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
- // oxlint-disable-next-line anti-slop/no-conditional-empty-object-spread -- validated external boundary or fixture contract.
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(options.fetch ? { fetch: options.fetch } : {});
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
- // oxlint-disable-next-line anti-slop/no-conditional-empty-object-spread -- validated boundary or fixture contract.
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
- // oxlint-disable-next-line anti-slop/no-conditional-empty-object-spread -- validated boundary or fixture contract.
131
- ...(input.cursor === undefined ? {} : { pagination_token: input.cursor }),
132
- // oxlint-disable-next-line anti-slop/no-conditional-empty-object-spread -- validated boundary or fixture contract.
133
- ...(input.limit === undefined ? {} : { max_results: String(input.limit) }),
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
- // oxlint-disable-next-line anti-slop/no-conditional-empty-object-spread -- validated boundary or fixture contract.
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
- // oxlint-disable-next-line anti-slop/no-conditional-empty-object-spread -- validated boundary or fixture contract.
211
- ...(input.cursor === undefined ? {} : { next_token: input.cursor }),
212
- // oxlint-disable-next-line anti-slop/no-conditional-empty-object-spread -- validated boundary or fixture contract.
213
- ...(input.limit === undefined ? {} : { max_results: String(input.limit) }),
214
- // oxlint-disable-next-line anti-slop/no-conditional-empty-object-spread -- validated boundary or fixture contract.
215
- ...(input.startTime === undefined ? {} : { start_time: input.startTime }),
216
- // oxlint-disable-next-line anti-slop/no-conditional-empty-object-spread -- validated boundary or fixture contract.
217
- ...(input.endTime === undefined ? {} : { end_time: input.endTime }),
218
- // oxlint-disable-next-line anti-slop/no-conditional-empty-object-spread -- validated boundary or fixture contract.
219
- ...(nativeInput?.sortOrder === undefined ? {} : { sort_order: nativeInput.sortOrder }),
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
- return Object.fromEntries(["id", "text", ...requestedFields].flatMap((field, index, fields) => fields.indexOf(field) === index && field in row ? [[field, row[field]]] : []));
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
- // oxlint-disable-next-line anti-slop/no-conditional-empty-object-spread -- validated boundary or fixture contract.
255
- ...(nextCursor === undefined ? {} : { nextCursor }),
256
- ...(includes === undefined
257
- ? {}
258
- : {
259
- // SAFETY: X's `includes` member is a JSON object validated by the transport boundary.
260
- metadata: { includes: object(includes) },
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
- ...(input.cursor === undefined ? {} : { pagination_token: input.cursor }),
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((entry) => object(entry));
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, ...(nextCursor === undefined ? {} : { nextCursor }) };
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
- // oxlint-disable-next-line anti-slop/no-runtime-typeof -- validated boundary or fixture contract.
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
- // oxlint-disable-next-line anti-slop/no-conditional-empty-object-spread -- validated boundary or fixture contract.
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. Video/GIF processing is outside this slice.",
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: "not-implemented-by-adapter",
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: "not-implemented-by-adapter",
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: "Filtered stream rules and streaming transport are not implemented by this adapter.",
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
- ...(typeof user["name"] === "string" ? { displayName: user["name"] } : {}),
629
- ...(typeof user["username"] === "string" ? { handle: user["username"] } : {}),
630
- ...(typeof user["description"] === "string" ? { bio: user["description"] } : {}),
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
- ...(input.cursor === undefined ? {} : { cursor: input.cursor }),
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
- ...(result.nextCursor === undefined ? {} : { nextCursor: result.nextCursor }),
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", "Scheduling needs an application runner; place URLs explicitly in text.");
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 = target.options === undefined ? {} : object(target.options);
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
- if (media.length > 4)
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
- if (item.kind !== "image" ||
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 uploadImage(media, context));
783
- const settings = target.options === undefined ? {} : object(target.options);
784
- return createPost(target.account, target.content.text ?? "", context, {
785
- // oxlint-disable-next-line anti-slop/no-conditional-empty-object-spread -- validated boundary or fixture contract.
786
- ...(ids.length ? { media: { media_ids: ids } } : {}),
787
- // oxlint-disable-next-line anti-slop/no-conditional-empty-object-spread -- validated boundary or fixture contract.
788
- ...(target.replyTo ? { reply: { in_reply_to_tweet_id: target.replyTo.postId } } : {}),
789
- // oxlint-disable-next-line anti-slop/no-conditional-empty-object-spread -- validated boundary or fixture contract.
790
- ...(settings["replySettings"] && settings["replySettings"] !== "everyone"
791
- ? { reply_settings: string(settings["replySettings"]) }
792
- : {}),
793
- }, target.targetIndex);
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
- throw new SocialError({
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 state = parseObject(JSON.parse(input.cursor));
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
- ...(eventCursor === undefined ? {} : { cursor: eventCursor }),
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
- ...(input.cursor === undefined ? {} : { cursor: input.cursor }),
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
- ...(cursor === undefined ? {} : { pagination_token: cursor }),
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
- ...(nextCursor === undefined ? {} : { nextCursor }),
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, ...(attachments === undefined ? {} : { attachments }) }));
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 === undefined ? {} : { 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 === undefined ? {} : { 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;