@opencoredev/social-sdk 0.3.0 → 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 +72 -0
  37. package/dist/platforms/x.js +328 -119
  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,23 +230,97 @@ 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
+ }),
262
251
  };
263
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(","),
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 };
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);
266
326
  const isTweets = resource === "tweets";
@@ -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) {
@@ -484,11 +541,11 @@ export function x(options) {
484
541
  Authorization: `Bearer ${options.auth.accessToken}`,
485
542
  "Content-Type": "application/json",
486
543
  },
487
- // oxlint-disable-next-line anti-slop/no-conditional-empty-object-spread -- FINALIZE carries no body.
488
- ...(body === undefined ? {} : { body: JSON.stringify(body) }),
489
544
  timeoutMs: remainingBudget(context),
490
- // oxlint-disable-next-line anti-slop/no-conditional-empty-object-spread -- validated boundary or fixture contract.
491
- ...(context.signal ? { signal: context.signal } : {}),
545
+ ...definedFields({
546
+ body: body === undefined ? undefined : JSON.stringify(body),
547
+ signal: context.signal,
548
+ }),
492
549
  });
493
550
  }
494
551
  catch (error) {
@@ -510,8 +567,7 @@ export function x(options) {
510
567
  headers: { Authorization: `Bearer ${options.auth.accessToken}` },
511
568
  body,
512
569
  timeoutMs: remainingBudget(context),
513
- // oxlint-disable-next-line anti-slop/no-conditional-empty-object-spread -- validated boundary or fixture contract.
514
- ...(context.signal ? { signal: context.signal } : {}),
570
+ ...definedFields({ signal: context.signal }),
515
571
  });
516
572
  }
517
573
  catch (error) {
@@ -529,8 +585,7 @@ export function x(options) {
529
585
  method: "GET",
530
586
  headers: { Authorization: `Bearer ${options.auth.accessToken}` },
531
587
  timeoutMs: remainingBudget(context),
532
- // oxlint-disable-next-line anti-slop/no-conditional-empty-object-spread -- validated boundary or fixture contract.
533
- ...(context.signal ? { signal: context.signal } : {}),
588
+ ...definedFields({ signal: context.signal }),
534
589
  });
535
590
  }
536
591
  catch (error) {
@@ -692,6 +747,7 @@ export function x(options) {
692
747
  apiRevision: "X API v2 / OpenAPI 2.168",
693
748
  runtime: ["node22", "node24", "bun"],
694
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."),
695
751
  {
696
752
  platform: "x",
697
753
  operation: "posts.publish",
@@ -744,6 +800,14 @@ export function x(options) {
744
800
  availability: "available",
745
801
  requiredScopes: ["tweet.read", "tweet.write", "users.read"],
746
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
+ },
747
811
  {
748
812
  platform: "x",
749
813
  operation: "graph.read",
@@ -821,6 +885,13 @@ export function x(options) {
821
885
  availability: "available",
822
886
  requiredScopes: ["dm.write"],
823
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
+ },
824
895
  {
825
896
  platform: "x",
826
897
  operation: "search.posts",
@@ -850,6 +921,12 @@ export function x(options) {
850
921
  requiredScopes: ["dm.read", "users.read", "tweet.read"],
851
922
  notes: "Requires a user-context token and an X API tier that includes Direct Messages.",
852
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
+ },
853
930
  {
854
931
  platform: "x",
855
932
  operation: "messages.write",
@@ -857,11 +934,30 @@ export function x(options) {
857
934
  requiredScopes: ["dm.write", "dm.read", "users.read", "tweet.read"],
858
935
  notes: "Requires a user-context token and an X API tier that includes Direct Messages.",
859
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
+ },
860
944
  {
861
945
  platform: "x",
862
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",
863
953
  availability: "not-implemented-by-adapter",
864
- 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.",
865
961
  },
866
962
  ],
867
963
  },
@@ -890,16 +986,17 @@ export function x(options) {
890
986
  accountId: account.accountId,
891
987
  profileId: id,
892
988
  }),
893
- ...(typeof user["name"] === "string" ? { displayName: user["name"] } : {}),
894
- ...(typeof user["username"] === "string" ? { handle: user["username"] } : {}),
895
- ...(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
+ }),
896
994
  native: user,
897
995
  };
898
996
  },
899
997
  async listRelationships(account, input, context) {
900
998
  const pagination = {
901
- ...(input.cursor === undefined ? {} : { cursor: input.cursor }),
902
- ...(input.limit === undefined ? {} : { limit: input.limit }),
999
+ ...definedFields({ cursor: input.cursor, limit: input.limit }),
903
1000
  };
904
1001
  const result = input.kind === "following"
905
1002
  ? await nativeAdapter.following({
@@ -944,7 +1041,7 @@ export function x(options) {
944
1041
  : input.kind,
945
1042
  };
946
1043
  }),
947
- ...(result.nextCursor === undefined ? {} : { nextCursor: result.nextCursor }),
1044
+ ...definedFields({ nextCursor: result.nextCursor }),
948
1045
  };
949
1046
  },
950
1047
  async follow(target, context) {
@@ -1013,14 +1110,14 @@ export function x(options) {
1013
1110
  if (text && !isValidXText(text))
1014
1111
  fail("x.text", "Text exceeds X's weighted 280-character limit or contains invalid characters.");
1015
1112
  if (target.schedule || target.content.link)
1016
- 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.");
1017
1114
  if (target.replyTo &&
1018
1115
  (target.replyTo.platform !== "x" ||
1019
1116
  target.replyTo.backend !== target.account.backend ||
1020
1117
  target.replyTo.accountId !== target.account.accountId ||
1021
1118
  target.replyTo.kind !== "platform-post"))
1022
1119
  fail("x.reply", "Use a platform-post reply reference authorized for this account and backend.");
1023
- const settings = target.options === undefined ? {} : object(target.options);
1120
+ const settings = optionsObject(target);
1024
1121
  if (Object.keys(settings).some((key) => key !== "replySettings") ||
1025
1122
  (settings["replySettings"] !== undefined &&
1026
1123
  !["everyone", "following", "mentionedUsers"].includes(String(settings["replySettings"]))))
@@ -1063,18 +1160,15 @@ export function x(options) {
1063
1160
  const ids = [];
1064
1161
  for (const media of target.content.media ?? [])
1065
1162
  ids.push(await uploadXMedia(media, context));
1066
- const settings = target.options === undefined ? {} : object(target.options);
1163
+ const replySettings = optionsObject(target)["replySettings"];
1067
1164
  const hasVideo = (target.content.media ?? []).some((media) => chunkedCategory(media) === "tweet_video");
1068
1165
  try {
1069
1166
  return await createPost(target.account, target.content.text ?? "", context, {
1070
- // oxlint-disable-next-line anti-slop/no-conditional-empty-object-spread -- validated boundary or fixture contract.
1071
- ...(ids.length ? { media: { media_ids: ids } } : {}),
1072
- // oxlint-disable-next-line anti-slop/no-conditional-empty-object-spread -- validated boundary or fixture contract.
1073
- ...(target.replyTo ? { reply: { in_reply_to_tweet_id: target.replyTo.postId } } : {}),
1074
- // oxlint-disable-next-line anti-slop/no-conditional-empty-object-spread -- validated boundary or fixture contract.
1075
- ...(settings["replySettings"] && settings["replySettings"] !== "everyone"
1076
- ? { reply_settings: string(settings["replySettings"]) }
1077
- : {}),
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
+ }),
1078
1172
  }, target.targetIndex);
1079
1173
  }
1080
1174
  catch (error) {
@@ -1116,12 +1210,8 @@ export function x(options) {
1116
1210
  },
1117
1211
  },
1118
1212
  comments: {
1119
- async list() {
1120
- throw new SocialError({
1121
- code: "unsupported_capability",
1122
- operation: "comments.read",
1123
- message: "X reply search is not implemented in this slice.",
1124
- });
1213
+ async list(ref, input, context) {
1214
+ return listReplies(ref, input, context);
1125
1215
  },
1126
1216
  async reply(ref, content, context) {
1127
1217
  await validateReplyParent(ref, context);
@@ -1162,7 +1252,10 @@ export function x(options) {
1162
1252
  const seen = [];
1163
1253
  if (input.cursor !== undefined) {
1164
1254
  try {
1165
- 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);
1166
1259
  eventCursor = optionalString(state["c"]);
1167
1260
  const hashes = string(state["s"]);
1168
1261
  if (hashes.length % conversationHashWidth !== 0)
@@ -1188,8 +1281,7 @@ export function x(options) {
1188
1281
  for (let fetches = 0; fetches < 10; fetches++) {
1189
1282
  const page = await nativeAdapter.listDirectMessages({
1190
1283
  account,
1191
- ...(eventCursor === undefined ? {} : { cursor: eventCursor }),
1192
- ...(input.limit === undefined ? {} : { limit: input.limit }),
1284
+ ...definedFields({ cursor: eventCursor, limit: input.limit }),
1193
1285
  context,
1194
1286
  });
1195
1287
  for (const event of page.items) {
@@ -1225,8 +1317,7 @@ export function x(options) {
1225
1317
  accountId: conversation.accountId,
1226
1318
  }),
1227
1319
  conversationId: conversation.conversationId,
1228
- ...(input.cursor === undefined ? {} : { cursor: input.cursor }),
1229
- ...(input.limit === undefined ? {} : { limit: input.limit }),
1320
+ ...definedFields({ cursor: input.cursor, limit: input.limit }),
1230
1321
  context,
1231
1322
  });
1232
1323
  },
@@ -1280,6 +1371,7 @@ export function x(options) {
1280
1371
  });
1281
1372
  },
1282
1373
  },
1374
+ webhooks: directWebhooks("x", (input) => verifyXWebhook({ ...input, secret: options.webhookSecret ?? "" }), now),
1283
1375
  native: (nativeAdapter = {
1284
1376
  async searchRecentPosts({ account, search, context }) {
1285
1377
  return searchPosts(account, { ...search, scope: "recent" }, context, search);
@@ -1290,20 +1382,65 @@ export function x(options) {
1290
1382
  readPost,
1291
1383
  async repost({ account, postId, context }) {
1292
1384
  authorize(account, context);
1293
- // oxlint-disable-next-line anti-slop/require-safety-comment-for-type-assertion -- validated boundary or fixture contract.
1294
1385
  return object(await request(`/2/users/${encodeURIComponent(account.accountId)}/retweets`, context, {
1295
1386
  tweet_id: postId,
1296
1387
  }));
1297
1388
  },
1298
1389
  async quote({ account, text, quotedPostId, context }) {
1299
1390
  authorize(account, context);
1300
- // oxlint-disable-next-line anti-slop/require-safety-comment-for-type-assertion -- validated boundary or fixture contract.
1301
1391
  return object(await request("/2/tweets", context, { text, quote_tweet_id: quotedPostId }));
1302
1392
  },
1303
1393
  async deletePost({ account, postId, context }) {
1304
1394
  authorize(account, context);
1305
1395
  await request(`/2/tweets/${encodeURIComponent(postId)}`, context, undefined, {}, "DELETE");
1306
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
+ },
1307
1444
  async uploadVideo({ account, video, context }) {
1308
1445
  authorize(account, context);
1309
1446
  const media = {
@@ -1333,7 +1470,6 @@ export function x(options) {
1333
1470
  operation: "x.polls.create",
1334
1471
  message: "X polls require 2-4 options and a duration from 5 minutes to 7 days.",
1335
1472
  });
1336
- // oxlint-disable-next-line anti-slop/require-safety-comment-for-type-assertion -- validated boundary or fixture contract.
1337
1473
  return object(await request("/2/tweets", context, {
1338
1474
  text,
1339
1475
  poll: { options: [...pollOptions], duration_minutes: durationMinutes },
@@ -1341,7 +1477,6 @@ export function x(options) {
1341
1477
  },
1342
1478
  async bookmarks({ account, context }) {
1343
1479
  authorize(account, context);
1344
- // oxlint-disable-next-line anti-slop/require-safety-comment-for-type-assertion -- validated boundary or fixture contract.
1345
1480
  return object(await request(`/2/users/${encodeURIComponent(account.accountId)}/bookmarks`, context));
1346
1481
  },
1347
1482
  async bookmark({ account, postId, context }) {
@@ -1356,7 +1491,6 @@ export function x(options) {
1356
1491
  },
1357
1492
  async follow({ account, userId, context }) {
1358
1493
  authorize(account, context);
1359
- // oxlint-disable-next-line anti-slop/require-safety-comment-for-type-assertion -- validated boundary or fixture contract.
1360
1494
  return object(await request(`/2/users/${encodeURIComponent(account.accountId)}/following`, context, {
1361
1495
  target_user_id: userId,
1362
1496
  }));
@@ -1381,14 +1515,13 @@ export function x(options) {
1381
1515
  const result = object(await request(path, context, undefined, {
1382
1516
  "dm_event.fields": "id,text,event_type,created_at,dm_conversation_id,attachments,entities",
1383
1517
  expansions: "sender_id,participant_ids",
1384
- ...(cursor === undefined ? {} : { pagination_token: cursor }),
1385
- ...(limit === undefined ? {} : { max_results: String(limit) }),
1518
+ ...definedFields({ pagination_token: cursor, max_results: limit?.toString() }),
1386
1519
  }));
1387
1520
  const meta = result["meta"] === undefined ? {} : object(result["meta"]);
1388
1521
  const nextCursor = optionalString(meta["next_token"]);
1389
1522
  return {
1390
1523
  items: result["data"] === undefined ? [] : array(result["data"]).map(object),
1391
- ...(nextCursor === undefined ? {} : { nextCursor }),
1524
+ ...definedFields({ nextCursor }),
1392
1525
  };
1393
1526
  },
1394
1527
  async sendDirectMessage({ account, participantId, text, context }) {
@@ -1410,7 +1543,7 @@ export function x(options) {
1410
1543
  operation: "messages.conversation.write",
1411
1544
  message: "X direct messages require non-empty text.",
1412
1545
  });
1413
- 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 }) }));
1414
1547
  },
1415
1548
  async createGroupConversation({ account, participantIds, message, context }) {
1416
1549
  authorize(account, context);
@@ -1525,16 +1658,13 @@ export function x(options) {
1525
1658
  authorize(account, context);
1526
1659
  return object(await request("/2/lists", context, {
1527
1660
  name,
1528
- ...(description === undefined ? {} : { description }),
1529
- ...(isPrivate === undefined ? {} : { private: isPrivate }),
1661
+ ...definedFields({ description, private: isPrivate }),
1530
1662
  }));
1531
1663
  },
1532
1664
  async updateList({ account, listId, name, description, isPrivate, context }) {
1533
1665
  authorize(account, context);
1534
1666
  return object(await request(`/2/lists/${encodeURIComponent(listId)}`, context, {
1535
- ...(name === undefined ? {} : { name }),
1536
- ...(description === undefined ? {} : { description }),
1537
- ...(isPrivate === undefined ? {} : { private: isPrivate }),
1667
+ ...definedFields({ name, description, private: isPrivate }),
1538
1668
  }, {}, "PUT"));
1539
1669
  },
1540
1670
  async deleteList({ account, listId, context }) {
@@ -1600,6 +1730,85 @@ export function x(options) {
1600
1730
  authorize(account, context);
1601
1731
  await request(`/2/users/${encodeURIComponent(account.accountId)}/retweets/${encodeURIComponent(postId)}`, context, undefined, {}, "DELETE");
1602
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
+ },
1603
1812
  }),
1604
1813
  });
1605
1814
  return adapter;