@opencoredev/social-sdk 0.3.0 → 0.5.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 (65) 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 +24 -21
  5. package/dist/cloud/common.d.ts +13 -8
  6. package/dist/cloud/common.js +40 -60
  7. package/dist/cloud/lifecycle.js +31 -35
  8. package/dist/cloud/media.d.ts +6 -2
  9. package/dist/cloud/media.js +50 -37
  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/postfast.d.ts +54 -0
  14. package/dist/cloud/postfast.js +578 -0
  15. package/dist/cloud/zernio.js +58 -98
  16. package/dist/core/client.js +79 -99
  17. package/dist/core/fields.d.ts +14 -0
  18. package/dist/core/fields.js +14 -0
  19. package/dist/core/idempotency.d.ts +7 -2
  20. package/dist/core/idempotency.js +37 -20
  21. package/dist/core/pagination.js +8 -7
  22. package/dist/core/types.d.ts +3 -2
  23. package/dist/platforms/bluesky.d.ts +65 -1
  24. package/dist/platforms/bluesky.js +675 -276
  25. package/dist/platforms/instagram.d.ts +2 -0
  26. package/dist/platforms/instagram.js +130 -105
  27. package/dist/platforms/linkedin.d.ts +58 -1
  28. package/dist/platforms/linkedin.js +877 -107
  29. package/dist/platforms/threads.d.ts +13 -1
  30. package/dist/platforms/threads.js +204 -302
  31. package/dist/platforms/tiktok.d.ts +4 -0
  32. package/dist/platforms/tiktok.js +140 -124
  33. package/dist/platforms/webhook-adapter.d.ts +9 -0
  34. package/dist/platforms/webhook-adapter.js +24 -0
  35. package/dist/platforms/x-engagement.js +7 -12
  36. package/dist/platforms/x-stream.d.ts +83 -0
  37. package/dist/platforms/x-stream.js +350 -0
  38. package/dist/platforms/x.d.ts +72 -0
  39. package/dist/platforms/x.js +328 -119
  40. package/dist/platforms/youtube-upload.d.ts +1 -1
  41. package/dist/platforms/youtube-upload.js +6 -2
  42. package/dist/platforms/youtube.d.ts +28 -4
  43. package/dist/platforms/youtube.js +291 -133
  44. package/dist/server/bluesky-oauth.d.ts +177 -0
  45. package/dist/server/bluesky-oauth.js +1229 -0
  46. package/dist/server/connections.d.ts +14 -0
  47. package/dist/server/connections.js +10 -2
  48. package/dist/server/egress.d.ts +14 -0
  49. package/dist/server/egress.js +115 -0
  50. package/dist/server/oauth-internal.d.ts +6 -0
  51. package/dist/server/oauth-internal.js +66 -0
  52. package/dist/server/oauth.d.ts +1 -1
  53. package/dist/server/oauth.js +46 -99
  54. package/dist/server/webhooks.d.ts +136 -3
  55. package/dist/server/webhooks.js +639 -25
  56. package/dist/testing/index.js +14 -28
  57. package/dist/transport/http.d.ts +1 -1
  58. package/dist/transport/http.js +0 -1
  59. package/dist/transport/json.d.ts +7 -0
  60. package/dist/transport/json.js +32 -4
  61. package/dist/transport/upload.d.ts +1 -1
  62. package/dist/transport/upload.js +46 -38
  63. package/dist/transport/validation.d.ts +16 -5
  64. package/dist/transport/validation.js +29 -7
  65. package/package.json +6 -2
@@ -1,14 +1,19 @@
1
- /* oxlint-disable anti-slop/no-chained-type-assertions, anti-slop/no-known-value-widening, anti-slop/require-safety-comment-for-type-assertion, anti-slop/require-readable-spacing, anti-slop/no-conditional-empty-object-spread, anti-slop/no-runtime-typeof -- validated external boundary or fixture contract. */
2
1
  import { readBinary } from "../transport/binary.js";
3
2
  import { remainingBudget } from "../transport/budget.js";
4
3
  import { connectedAccountRef, defineAdapter, platformPostRef, profileRef, } from "../core/index.js";
5
4
  import { SocialError } from "../core/errors.js";
5
+ import { definedFields } from "../core/fields.js";
6
6
  import { abortable, createHttp, HttpError } from "../transport/http.js";
7
7
  import { httpsUrl } from "../transport/upload.js";
8
- import { array, object, string } from "../transport/validation.js";
8
+ import { array, isBoolean, isFiniteNumber, isString, object, optionalNumber, optionalString, string, } from "../transport/validation.js";
9
9
  import { publicFields } from "../cloud/common.js";
10
10
  const MAX_IMAGE_BYTES = 10 * 1024 * 1024;
11
11
  const MAX_IMAGES = 4;
12
+ /** app.bsky.embed.video accepts one MP4 blob of at most 300,000,000 bytes. */
13
+ const MAX_VIDEO_BYTES = 300_000_000;
14
+ const DEFAULT_VIDEO_SERVICE = "https://video.bsky.app";
15
+ /** Bluesky's video guide recommends a 30 minute upload token lifetime. */
16
+ const VIDEO_UPLOAD_TOKEN_SECONDS = 30 * 60;
12
17
  function endpoint(service, method) {
13
18
  let base;
14
19
  try {
@@ -36,11 +41,11 @@ function accountMatches(ref, auth, backend) {
36
41
  function authHeaders(auth) {
37
42
  return auth.accessJwt === undefined ? {} : { Authorization: `Bearer ${auth.accessJwt}` };
38
43
  }
39
- // oxlint-disable-next-line anti-slop/no-unknown-parameters -- validated boundary or fixture contract.
40
- function operationError(operation, error, mutation = true) {
41
- if (error instanceof SocialError)
42
- return error;
43
- if (error instanceof HttpError) {
44
+ function operationError(operation, cause, mutation = true) {
45
+ if (cause instanceof SocialError)
46
+ return cause;
47
+ if (cause instanceof HttpError) {
48
+ const error = cause;
44
49
  const ambiguous = mutation &&
45
50
  error.dispatched &&
46
51
  (error.kind !== "http" || (error.status !== undefined && error.status >= 500));
@@ -72,8 +77,7 @@ function operationError(operation, error, mutation = true) {
72
77
  : !mutation && error.status !== undefined && error.status >= 500
73
78
  ? { kind: "after-delay", delayMs: 1000 }
74
79
  : { kind: "never" },
75
- // oxlint-disable-next-line anti-slop/no-conditional-empty-object-spread -- validated boundary or fixture contract.
76
- ...(error.status === undefined ? {} : { upstreamStatus: error.status }),
80
+ ...definedFields({ upstreamStatus: error.status }),
77
81
  });
78
82
  }
79
83
  return new SocialError({
@@ -81,14 +85,90 @@ function operationError(operation, error, mutation = true) {
81
85
  operation,
82
86
  message: "Bluesky request failed.",
83
87
  retryDisposition: { kind: "reconcile-first" },
84
- cause: error,
88
+ cause,
85
89
  });
86
90
  }
87
- // oxlint-disable-next-line anti-slop/no-unknown-parameters -- validated boundary or fixture contract.
88
91
  function postRef(value) {
89
92
  const record = object(value);
90
93
  return { uri: string(record["uri"]), cid: string(record["cid"]) };
91
94
  }
95
+ function videoServiceOrigin(value) {
96
+ let url;
97
+ try {
98
+ url = new URL(value ?? DEFAULT_VIDEO_SERVICE);
99
+ }
100
+ catch {
101
+ throw new SocialError({
102
+ code: "invalid_config",
103
+ operation: "bluesky.configure",
104
+ message: "Bluesky video service must be an absolute URL.",
105
+ });
106
+ }
107
+ if (url.protocol !== "https:" || url.username || url.password || url.pathname !== "/")
108
+ throw new SocialError({
109
+ code: "invalid_config",
110
+ operation: "bluesky.configure",
111
+ message: "Bluesky video service must be an HTTPS origin without credentials or a path.",
112
+ });
113
+ return url;
114
+ }
115
+ function serviceDid(value, operation) {
116
+ if (!/^did:[a-z]+:[A-Za-z0-9._:%-]{1,2000}$/.test(value))
117
+ throw new SocialError({
118
+ code: "invalid_config",
119
+ operation,
120
+ message: "Bluesky PDS service DID is malformed.",
121
+ });
122
+ return value;
123
+ }
124
+ /** Validates a processed video blob before it is written into a post record. */
125
+ function videoBlob(value) {
126
+ const blob = object(value);
127
+ const link = object(blob["ref"])["$link"];
128
+ const size = blob["size"];
129
+ if (blob["$type"] !== "blob" ||
130
+ !isString(link) ||
131
+ !/^[a-z0-9]{8,128}$/i.test(link) ||
132
+ blob["mimeType"] !== "video/mp4" ||
133
+ !isFiniteNumber(size) ||
134
+ !Number.isSafeInteger(size) ||
135
+ size <= 0 ||
136
+ size > MAX_VIDEO_BYTES)
137
+ throw new SocialError({
138
+ code: "media_error",
139
+ operation: "bluesky.video.job",
140
+ message: "Bluesky returned a video blob that cannot be embedded.",
141
+ retryDisposition: { kind: "never" },
142
+ });
143
+ return { $type: "blob", ref: { $link: link }, mimeType: "video/mp4", size };
144
+ }
145
+ /** Parses app.bsky.video.defs#jobStatus and binds it to the configured DID. */
146
+ function videoJob(value, did) {
147
+ const job = object(value);
148
+ const owner = string(job["did"]);
149
+ if (owner !== did)
150
+ throw new SocialError({
151
+ code: "unauthorized",
152
+ operation: "bluesky.video.job",
153
+ message: "Bluesky video job belongs to a different DID.",
154
+ });
155
+ const progress = optionalNumber(job["progress"]);
156
+ const failureCode = optionalString(job["failureCode"]);
157
+ const error = optionalString(job["error"]);
158
+ const message = optionalString(job["message"]);
159
+ return {
160
+ jobId: string(job["jobId"]),
161
+ did: owner,
162
+ state: string(job["state"]),
163
+ ...definedFields({
164
+ progress,
165
+ blob: job["blob"] === undefined ? undefined : videoBlob(job["blob"]),
166
+ failureCode,
167
+ error,
168
+ message,
169
+ }),
170
+ };
171
+ }
92
172
  function linkFacets(text) {
93
173
  const facets = [];
94
174
  const urlPattern = /https?:\/\/[^\s<>]+/g;
@@ -107,111 +187,91 @@ function linkFacets(text) {
107
187
  }
108
188
  return facets;
109
189
  }
110
- // oxlint-disable-next-line anti-slop/no-unknown-parameters -- validated boundary or fixture contract.
111
- function richTextOptions(text, options) {
112
- const invalid = (message) => {
113
- throw new SocialError({ code: "invalid_input", operation: "bluesky.prepare", message });
114
- };
115
- if (options !== undefined &&
116
- // oxlint-disable-next-line anti-slop/no-runtime-typeof -- validated boundary or fixture contract.
117
- (options === null || typeof options !== "object" || Array.isArray(options)))
118
- invalid("Bluesky options must be an object.");
119
- // oxlint-disable-next-line anti-slop/require-safety-comment-for-type-assertion -- validated boundary or fixture contract.
120
- const config = (options ?? {});
190
+ function invalidRichText(message) {
191
+ throw new SocialError({ code: "invalid_input", operation: "bluesky.prepare", message });
192
+ }
193
+ /** Caller-supplied publish options arrive untyped; this only admits non-array objects. */
194
+ function isOptionBag(value) {
195
+ return typeof value === "object" && value !== null && !Array.isArray(value);
196
+ }
197
+ function isOptionList(value) {
198
+ return Array.isArray(value);
199
+ }
200
+ function isStringItem(value) {
201
+ return typeof value === "string";
202
+ }
203
+ function isStringList(value) {
204
+ return Array.isArray(value) && value.every(isStringItem);
205
+ }
206
+ function hasMentionFields(value) {
207
+ return (isOptionBag(value) &&
208
+ "byteStart" in value &&
209
+ "byteEnd" in value &&
210
+ "did" in value &&
211
+ typeof value.byteStart === "number" &&
212
+ typeof value.byteEnd === "number" &&
213
+ typeof value.did === "string");
214
+ }
215
+ function isJsonBody(body) {
216
+ return typeof body === "string";
217
+ }
218
+ function richTextOptions(text, target) {
219
+ const options = target.options;
220
+ if (options !== undefined && !isOptionBag(options))
221
+ invalidRichText("Bluesky options must be an object.");
222
+ const config = options ?? {};
121
223
  if (Object.keys(config).some((key) => key !== "languages" && key !== "mentions"))
122
- invalid("A supplied Bluesky option is not supported.");
224
+ invalidRichText("A supplied Bluesky option is not supported.");
123
225
  let langs;
124
- if (config["languages"] !== undefined) {
125
- const languages = config["languages"];
126
- if (!Array.isArray(languages) ||
127
- languages.length > 3 ||
128
- // oxlint-disable-next-line anti-slop/no-runtime-typeof -- validated boundary or fixture contract.
129
- languages.some((value) => typeof value !== "string"))
130
- invalid("Bluesky accepts at most three language tags.");
226
+ const languages = "languages" in config ? config.languages : undefined;
227
+ if (languages !== undefined) {
228
+ if (!isStringList(languages) || languages.length > 3)
229
+ invalidRichText("Bluesky accepts at most three language tags.");
131
230
  try {
132
- // oxlint-disable-next-line anti-slop/require-safety-comment-for-type-assertion -- validated boundary or fixture contract.
133
- langs = Intl.getCanonicalLocales(languages);
231
+ langs = Intl.getCanonicalLocales([...languages]);
134
232
  }
135
233
  catch {
136
- invalid("Bluesky languages must be valid BCP 47 tags.");
234
+ invalidRichText("Bluesky languages must be valid BCP 47 tags.");
137
235
  }
138
236
  }
139
- const facets = [...linkFacets(text)];
237
+ const facets = linkFacets(text);
140
238
  const bytes = new TextEncoder().encode(text);
141
- const mentions = config["mentions"] ?? [];
142
- if (!Array.isArray(mentions) || mentions.length > 100)
143
- invalid("Bluesky mentions must be an array of at most 100 DID references.");
239
+ const mentions = ("mentions" in config ? config.mentions : undefined) ?? [];
240
+ if (!isOptionList(mentions) || mentions.length > 100)
241
+ invalidRichText("Bluesky mentions must be an array of at most 100 DID references.");
144
242
  const spans = [];
145
- // oxlint-disable-next-line anti-slop/require-safety-comment-for-type-assertion -- validated boundary or fixture contract.
146
243
  for (const value of mentions) {
147
- // oxlint-disable-next-line anti-slop/no-runtime-typeof -- validated boundary or fixture contract.
148
- if (!value || typeof value !== "object" || Array.isArray(value))
149
- invalid("Each mention requires UTF-8 offsets and a DID.");
150
- const start = value["byteStart"], end = value["byteEnd"], did = value["did"];
151
- if (
152
- // oxlint-disable-next-line anti-slop/no-runtime-typeof -- validated boundary or fixture contract.
153
- typeof start !== "number" ||
154
- // oxlint-disable-next-line anti-slop/no-runtime-typeof -- validated boundary or fixture contract.
155
- typeof end !== "number" ||
156
- !Number.isSafeInteger(start) ||
157
- !Number.isSafeInteger(end) ||
158
- start < 0 ||
159
- end <= start ||
160
- end > bytes.length ||
161
- (bytes[start] & 0xc0) === 0x80 ||
162
- (end < bytes.length && (bytes[end] & 0xc0) === 0x80) ||
163
- // oxlint-disable-next-line anti-slop/no-runtime-typeof -- validated boundary or fixture contract.
164
- typeof did !== "string" ||
165
- !/^did:[a-z]+:[A-Za-z0-9._:%-]+$/.test(did))
166
- invalid("Mention offsets must span complete UTF-8 characters and identify a DID.");
167
- // oxlint-disable-next-line anti-slop/require-safety-comment-for-type-assertion -- validated boundary or fixture contract.
168
- const byteStart = start,
169
- // oxlint-disable-next-line anti-slop/require-safety-comment-for-type-assertion -- validated boundary or fixture contract.
170
- byteEnd = end;
244
+ if (!isOptionBag(value))
245
+ invalidRichText("Each mention requires UTF-8 offsets and a DID.");
246
+ if (!hasMentionFields(value) ||
247
+ !Number.isSafeInteger(value.byteStart) ||
248
+ !Number.isSafeInteger(value.byteEnd) ||
249
+ value.byteStart < 0 ||
250
+ value.byteEnd <= value.byteStart ||
251
+ value.byteEnd > bytes.length ||
252
+ (bytes[value.byteStart] & 0xc0) === 0x80 ||
253
+ (value.byteEnd < bytes.length && (bytes[value.byteEnd] & 0xc0) === 0x80) ||
254
+ !/^did:[a-z]+:[A-Za-z0-9._:%-]+$/.test(value.did))
255
+ invalidRichText("Mention offsets must span complete UTF-8 characters and identify a DID.");
256
+ const { byteStart, byteEnd, did } = value;
171
257
  if (!new TextDecoder().decode(bytes.slice(byteStart, byteEnd)).startsWith("@"))
172
- invalid("A mention span must start with @.");
258
+ invalidRichText("A mention span must start with @.");
173
259
  if (spans.some((span) => byteStart < span.end && byteEnd > span.start) ||
174
- facets.some((facet) => {
175
- // oxlint-disable-next-line anti-slop/require-safety-comment-for-type-assertion -- validated boundary or fixture contract.
176
- const index = facet["index"];
177
- return byteStart < Number(index["byteEnd"]) && byteEnd > Number(index["byteStart"]);
178
- }))
179
- invalid("Mention spans must not overlap links or other mentions.");
260
+ facets.some((facet) => byteStart < facet.index.byteEnd && byteEnd > facet.index.byteStart))
261
+ invalidRichText("Mention spans must not overlap links or other mentions.");
180
262
  spans.push({ start: byteStart, end: byteEnd });
181
263
  facets.push({
182
264
  index: { byteStart, byteEnd },
183
- // oxlint-disable-next-line anti-slop/require-safety-comment-for-type-assertion -- validated boundary or fixture contract.
184
- features: [{ $type: "app.bsky.richtext.facet#mention", did: did }],
265
+ features: [{ $type: "app.bsky.richtext.facet#mention", did }],
185
266
  });
186
267
  }
187
- facets.sort((left, right) =>
188
- // oxlint-disable-next-line anti-slop/require-safety-comment-for-type-assertion -- validated boundary or fixture contract.
189
- Number(left["index"]["byteStart"]) -
190
- // oxlint-disable-next-line anti-slop/require-safety-comment-for-type-assertion -- validated boundary or fixture contract.
191
- Number(right["index"]["byteStart"]));
192
- // oxlint-disable-next-line anti-slop/no-conditional-empty-object-spread -- validated boundary or fixture contract.
193
- return { ...(langs === undefined ? {} : { langs }), ...(facets.length ? { facets } : {}) };
268
+ facets.sort((left, right) => left.index.byteStart - right.index.byteStart);
269
+ return definedFields({ langs, facets: facets.length ? facets : undefined });
194
270
  }
195
271
  function graphemeCount(text) {
196
- const Segmenter =
197
- // oxlint-disable-next-line anti-slop/no-chained-type-assertions -- validated boundary or fixture contract.
198
- // oxlint-disable-next-line anti-slop/require-safety-comment-for-type-assertion -- validated boundary or fixture contract.
199
- // oxlint-disable-next-line anti-slop/no-chained-type-assertions -- provider payload is validated at this adapter boundary.
200
- // oxlint-disable-next-line anti-slop/require-safety-comment-for-type-assertion -- provider payload is validated at this adapter boundary.
201
- // oxlint-disable-next-line anti-slop/no-chained-type-assertions -- validated external boundary or fixture contract.
202
- // oxlint-disable-next-line anti-slop/require-safety-comment-for-type-assertion -- validated external boundary or fixture contract.
203
- // oxlint-disable-next-line anti-slop/no-chained-type-assertions -- validated external boundary or fixture contract.
204
- // oxlint-disable-next-line anti-slop/require-safety-comment-for-type-assertion -- validated external boundary or fixture contract.
205
- // oxlint-disable-next-line anti-slop/no-chained-type-assertions -- validated external boundary or fixture contract.
206
- // oxlint-disable-next-line anti-slop/require-safety-comment-for-type-assertion -- validated external boundary or fixture contract.
207
- // oxlint-disable-next-line anti-slop/no-chained-type-assertions -- validated external boundary or fixture contract.
208
- // oxlint-disable-next-line anti-slop/require-safety-comment-for-type-assertion -- validated external boundary or fixture contract.
209
- // oxlint-disable-next-line anti-slop/no-chained-type-assertions -- validated external boundary or fixture contract.
210
- // oxlint-disable-next-line anti-slop/require-safety-comment-for-type-assertion -- validated external boundary or fixture contract.
211
- Intl.Segmenter;
212
- return Segmenter === undefined
272
+ return Intl.Segmenter === undefined
213
273
  ? Array.from(text).length
214
- : [...new Segmenter(undefined, { granularity: "grapheme" }).segment(text)].length;
274
+ : [...new Intl.Segmenter(undefined, { granularity: "grapheme" }).segment(text)].length;
215
275
  }
216
276
  async function readMedia(source, fetcher, context, allowMediaHost) {
217
277
  const duration = remainingBudget(context);
@@ -303,8 +363,7 @@ export function bluesky(options) {
303
363
  return options.session.fetchHandler(url.pathname + url.search, init);
304
364
  }
305
365
  : fetcher,
306
- // oxlint-disable-next-line anti-slop/no-conditional-empty-object-spread -- validated boundary or fixture contract.
307
- ...(options.timeoutMs === undefined ? {} : { timeoutMs: options.timeoutMs }),
366
+ ...definedFields({ timeoutMs: options.timeoutMs }),
308
367
  });
309
368
  const auth = options.auth;
310
369
  if (options.session) {
@@ -336,6 +395,12 @@ export function bluesky(options) {
336
395
  apiRevision: "AT Protocol XRPC 2026-09",
337
396
  runtime: ["node>=22.12", "bun"],
338
397
  capabilities: [
398
+ {
399
+ operation: "webhooks.verify",
400
+ platform: "bluesky",
401
+ availability: "unsupported-by-platform",
402
+ notes: "Bluesky has no signed webhook delivery. Events arrive over WebSocket streams you subscribe to: the relay firehose (com.atproto.sync.subscribeRepos) or Jetstream. See https://bsky.network/docs/consuming-the-firehose.",
403
+ },
339
404
  {
340
405
  operation: "accounts.read",
341
406
  platform: "bluesky",
@@ -346,9 +411,15 @@ export function bluesky(options) {
346
411
  operation: "posts.publish",
347
412
  platform: "bluesky",
348
413
  availability: "available",
349
- formats: ["text", "image"],
414
+ formats: ["text", "image", "video"],
350
415
  requiredScopes: ["repo"],
351
416
  },
417
+ {
418
+ operation: "posts.schedule",
419
+ platform: "bluesky",
420
+ availability: "unsupported-by-platform",
421
+ notes: "AT Protocol records are visible once written. The post createdAt field is a client-declared timestamp and does not delay publication.",
422
+ },
352
423
  {
353
424
  operation: "posts.read",
354
425
  platform: "bluesky",
@@ -404,9 +475,54 @@ export function bluesky(options) {
404
475
  {
405
476
  operation: "posts.publish.video",
406
477
  platform: "bluesky",
407
- availability: "not-implemented-by-adapter",
478
+ availability: "available",
408
479
  formats: ["video"],
409
480
  requiredScopes: ["repo"],
481
+ notes: "One MP4 per post from a media.upload reference. Publishing reads the video job once and creates the post only when the processed blob is ready; it never waits or polls.",
482
+ },
483
+ {
484
+ operation: "comments.moderate",
485
+ platform: "bluesky",
486
+ availability: "available",
487
+ requiredScopes: ["repo"],
488
+ notes: "Native hideReply adds or removes a reply URI in the root post's threadgate hiddenReplies list. Only the root post's author can hide replies.",
489
+ },
490
+ {
491
+ operation: "media.upload",
492
+ platform: "bluesky",
493
+ availability: "available",
494
+ formats: ["video"],
495
+ requiredScopes: ["repo"],
496
+ notes: "Uploads one video/mp4 Blob of at most 300,000,000 bytes to the Bluesky video service with a PDS service token. Returns the processing job ID as the media reference.",
497
+ },
498
+ {
499
+ operation: "media.video",
500
+ platform: "bluesky",
501
+ availability: "available",
502
+ formats: ["video"],
503
+ requiredScopes: ["repo"],
504
+ notes: "native.uploadVideo sends one app.bsky.video.uploadVideo request and returns the job.",
505
+ },
506
+ {
507
+ operation: "media.status",
508
+ platform: "bluesky",
509
+ availability: "available",
510
+ formats: ["video"],
511
+ notes: "native.getVideoJobStatus reads app.bsky.video.getJobStatus once per call.",
512
+ },
513
+ {
514
+ operation: "media.limits.read",
515
+ platform: "bluesky",
516
+ availability: "available",
517
+ formats: ["video"],
518
+ notes: "native.getVideoUploadLimits reads the daily video allowance. Uploads do not check it automatically.",
519
+ },
520
+ // Source, accessed 2026-09-24: https://github.com/bluesky-social/atproto/discussions/3038
521
+ {
522
+ operation: "posts.update",
523
+ platform: "bluesky",
524
+ availability: "unsupported-by-platform",
525
+ notes: "Bluesky treats posts as immutable. A putRecord rewrite succeeds on the PDS, but the Bluesky AppView ignores post updates and the new CID breaks existing strong references from replies, quotes, likes, and reposts.",
410
526
  },
411
527
  ...[
412
528
  "posts.repost",
@@ -434,7 +550,7 @@ export function bluesky(options) {
434
550
  async function xrpc(method, context, init = {}) {
435
551
  try {
436
552
  const headers = new Headers(init.headers);
437
- if (typeof init.body === "string" && !headers.has("Content-Type"))
553
+ if (isJsonBody(init.body) && !headers.has("Content-Type"))
438
554
  headers.set("Content-Type", "application/json");
439
555
  if (method.startsWith("chat.bsky.") && !headers.has("atproto-proxy"))
440
556
  headers.set("atproto-proxy", "did:web:api.bsky.chat#bsky_chat");
@@ -445,10 +561,7 @@ export function bluesky(options) {
445
561
  ...(options.session
446
562
  ? { headers }
447
563
  : { headers: { ...authHeaders(auth), ...Object.fromEntries(headers.entries()) } }),
448
- // oxlint-disable-next-line anti-slop/no-conditional-empty-object-spread -- validated boundary or fixture contract.
449
- ...(init.body === undefined ? {} : { body: init.body }),
450
- // oxlint-disable-next-line anti-slop/no-conditional-empty-object-spread -- validated boundary or fixture contract.
451
- ...(context.signal === undefined ? {} : { signal: context.signal }),
564
+ ...definedFields({ body: init.body, signal: context.signal }),
452
565
  maxAttempts: init.maxAttempts ??
453
566
  (init.body === undefined ? Math.min(context.retryBudget.maxAttempts, 3) : 1),
454
567
  });
@@ -469,10 +582,102 @@ export function bluesky(options) {
469
582
  message: "The configured credential belongs to a different DID.",
470
583
  });
471
584
  }
472
- // oxlint-disable-next-line anti-slop/no-runtime-typeof -- validated boundary or fixture contract.
473
- const handle = typeof session["handle"] === "string" ? session["handle"] : undefined;
474
- // oxlint-disable-next-line anti-slop/no-conditional-empty-object-spread -- validated boundary or fixture contract.
475
- return { did, ...(handle === undefined ? {} : { handle }) };
585
+ return { did, ...definedFields({ handle: optionalString(session["handle"]) }) };
586
+ }
587
+ const videoOrigin = videoServiceOrigin(options.videoService);
588
+ const timeout = options.timeoutMs === undefined ? {} : { timeoutMs: options.timeoutMs };
589
+ const videoHttp = createHttp({ fetch: fetcher, ...timeout });
590
+ // Bluesky's video guide notes that a video the service already processed is reported as
591
+ // `already_exists` together with the existing job and blob. The upload transport reads that
592
+ // 409 body as a job status; `videoJob` still validates it and binds it to this DID.
593
+ const videoUploadHttp = createHttp({
594
+ fetch: async (input, init) => {
595
+ const response = await fetcher(input, init);
596
+ return response.status === 409
597
+ ? new Response(response.body, { status: 200, headers: response.headers })
598
+ : response;
599
+ },
600
+ ...timeout,
601
+ });
602
+ async function videoRequest(method, query, context, init = {}) {
603
+ const url = new URL(`/xrpc/${method}`, videoOrigin);
604
+ url.search = query.toString();
605
+ try {
606
+ return await (init.body === undefined ? videoHttp : videoUploadHttp)({
607
+ url,
608
+ timeoutMs: remainingBudget(context),
609
+ method: init.body === undefined ? "GET" : "POST",
610
+ headers: definedFields({
611
+ Authorization: init.token === undefined ? undefined : `Bearer ${init.token}`,
612
+ "Content-Type": init.body === undefined ? undefined : "video/mp4",
613
+ }),
614
+ ...definedFields({ body: init.body, signal: context.signal }),
615
+ maxAttempts: init.body === undefined ? Math.min(context.retryBudget.maxAttempts, 3) : 1,
616
+ });
617
+ }
618
+ catch (error) {
619
+ throw operationError(`bluesky.${method}`, error, init.body !== undefined);
620
+ }
621
+ }
622
+ /** Requests a short-lived service token from the account's PDS. The token is never logged. */
623
+ async function serviceToken(aud, lxm, expiresInSeconds, context) {
624
+ const query = new URLSearchParams({ aud, lxm });
625
+ if (expiresInSeconds !== undefined)
626
+ query.set("exp", String(Math.floor(Date.now() / 1000) + expiresInSeconds));
627
+ const response = object(await xrpc(`com.atproto.server.getServiceAuth?${query.toString()}`, context));
628
+ return string(response["token"]);
629
+ }
630
+ /** Resolves the PDS service DID that the video service stores the processed blob with. */
631
+ async function pdsAudience(context) {
632
+ if (options.pdsDid !== undefined)
633
+ return serviceDid(options.pdsDid, "bluesky.video.upload");
634
+ const session = object(await xrpc("com.atproto.server.getSession", context));
635
+ const didDoc = session["didDoc"];
636
+ if (session["did"] !== auth.did || didDoc === undefined)
637
+ throw new SocialError({
638
+ code: "invalid_config",
639
+ operation: "bluesky.video.upload",
640
+ message: "The session did not include a DID document for this account. Configure pdsDid for video uploads.",
641
+ });
642
+ const document = object(didDoc);
643
+ const pds = array(document["service"] ?? []).find((entry) => {
644
+ const service = object(entry);
645
+ return optionalString(service["id"])?.endsWith("#atproto_pds") === true;
646
+ });
647
+ if (document["id"] !== auth.did || pds === undefined)
648
+ throw new SocialError({
649
+ code: "invalid_config",
650
+ operation: "bluesky.video.upload",
651
+ message: "The DID document does not name a PDS for this account. Configure pdsDid.",
652
+ });
653
+ let host;
654
+ try {
655
+ const url = new URL(string(object(pds)["serviceEndpoint"]));
656
+ if (url.protocol !== "https:")
657
+ throw new Error("PDS endpoint must use HTTPS");
658
+ host = url.host;
659
+ }
660
+ catch {
661
+ throw new SocialError({
662
+ code: "invalid_config",
663
+ operation: "bluesky.video.upload",
664
+ message: "The DID document names an invalid PDS endpoint. Configure pdsDid.",
665
+ });
666
+ }
667
+ return serviceDid(`did:web:${host.replace(":", "%3A")}`, "bluesky.video.upload");
668
+ }
669
+ /** Validates a normalized video attachment and returns its MP4 bytes without I/O. */
670
+ function videoSource(media) {
671
+ const source = media.source;
672
+ const mimeType = media.mimeType ?? (source.kind === "blob" ? source.blob.type || undefined : undefined);
673
+ if (media.kind !== "video" || source.kind !== "blob" || mimeType !== "video/mp4")
674
+ throw new SocialError({
675
+ code: "invalid_input",
676
+ operation: "media.upload",
677
+ message: "Bluesky video upload requires one video/mp4 Blob.",
678
+ retryDisposition: { kind: "never" },
679
+ });
680
+ return source.blob;
476
681
  }
477
682
  const nativeContext = (context, operation) => context ?? {
478
683
  correlationId: operation,
@@ -497,7 +702,7 @@ export function bluesky(options) {
497
702
  });
498
703
  return new URLSearchParams({
499
704
  limit: String(limit),
500
- ...(input.cursor ? { cursor: input.cursor } : {}),
705
+ ...definedFields({ cursor: input.cursor || undefined }),
501
706
  });
502
707
  };
503
708
  const recordKey = (uri, collection) => {
@@ -513,7 +718,7 @@ export function bluesky(options) {
513
718
  };
514
719
  const actorPage = (response, key) => ({
515
720
  actors: array(response[key] ?? []).map((value) => object(value)),
516
- ...(typeof response["cursor"] === "string" ? { cursor: response["cursor"] } : {}),
721
+ ...definedFields({ cursor: optionalString(response["cursor"]) }),
517
722
  });
518
723
  const native = {
519
724
  async getPost(input) {
@@ -532,7 +737,6 @@ export function bluesky(options) {
532
737
  operation: "bluesky.getPost",
533
738
  message: "Bluesky post was not found.",
534
739
  });
535
- // oxlint-disable-next-line anti-slop/require-safety-comment-for-type-assertion -- validated boundary or fixture contract.
536
740
  return object(post);
537
741
  },
538
742
  async getPostThread(input) {
@@ -542,7 +746,6 @@ export function bluesky(options) {
542
746
  backendInstance: backend,
543
747
  };
544
748
  const query = new URLSearchParams({ uri: input.uri });
545
- // oxlint-disable-next-line anti-slop/require-safety-comment-for-type-assertion -- validated boundary or fixture contract.
546
749
  return object(await xrpc(`app.bsky.feed.getPostThread?${query.toString()}`, context));
547
750
  },
548
751
  async searchPosts(input) {
@@ -604,23 +807,14 @@ export function bluesky(options) {
604
807
  query.append("tag", normalizedTag);
605
808
  }
606
809
  const response = object(await xrpc(`app.bsky.feed.searchPosts?${query}`, context));
607
- const posts = array(response["posts"]).map((post) => {
608
- const value = object(post);
609
- // The XRPC transport has already decoded a JSON payload; `object` validates
610
- // the record boundary while the recursive JSON shape is preserved for callers.
611
- // oxlint-disable-next-line anti-slop/require-safety-comment-for-type-assertion
612
- return value;
613
- });
614
- const cursorValue = response["cursor"];
615
- const hitsTotalValue = response["hitsTotal"];
810
+ const posts = array(response["posts"]).map((post) => object(post));
811
+ const hitsTotal = optionalNumber(response["hitsTotal"]);
616
812
  return {
617
813
  posts,
618
- // oxlint-disable-next-line anti-slop/no-conditional-empty-object-spread -- validated boundary or fixture contract.
619
- ...(typeof cursorValue === "string" ? { cursor: cursorValue } : {}),
620
- // oxlint-disable-next-line anti-slop/no-conditional-empty-object-spread -- validated boundary or fixture contract.
621
- ...(typeof hitsTotalValue === "number" && Number.isSafeInteger(hitsTotalValue)
622
- ? { hitsTotal: hitsTotalValue }
623
- : {}),
814
+ ...definedFields({
815
+ cursor: optionalString(response["cursor"]),
816
+ hitsTotal: hitsTotal !== undefined && Number.isSafeInteger(hitsTotal) ? hitsTotal : undefined,
817
+ }),
624
818
  };
625
819
  },
626
820
  async likePost(input) {
@@ -656,13 +850,10 @@ export function bluesky(options) {
656
850
  }));
657
851
  const prefix = `at://${auth.did}/app.bsky.feed.like/`;
658
852
  const uri = response["uri"], cid = response["cid"];
659
- if (
660
- // oxlint-disable-next-line anti-slop/no-runtime-typeof -- validated boundary or fixture contract.
661
- typeof uri !== "string" ||
853
+ if (!isString(uri) ||
662
854
  !uri.startsWith(prefix) ||
663
855
  !/^[A-Za-z0-9._~:-]{1,512}$/.test(uri.slice(prefix.length)) ||
664
- // oxlint-disable-next-line anti-slop/no-runtime-typeof -- validated boundary or fixture contract.
665
- typeof cid !== "string" ||
856
+ !isString(cid) ||
666
857
  !cid)
667
858
  throw new SocialError({
668
859
  code: "ambiguous_outcome",
@@ -751,17 +942,79 @@ export function bluesky(options) {
751
942
  body: JSON.stringify({ repo: auth.did, collection: "app.bsky.feed.post", rkey }),
752
943
  });
753
944
  },
754
- async uploadVideo(_input) {
755
- throw new SocialError({
756
- code: "unsupported_capability",
757
- operation: "bluesky.video.upload",
758
- message: "Bluesky video upload requires the video service authentication and job polling flow; this adapter does not implement it.",
759
- });
945
+ async uploadVideo(input) {
946
+ // https://docs.bsky.app/docs/tutorials/video (recommended method)
947
+ const context = nativeContext(input.context, "bluesky.video.upload");
948
+ assertNativeAccount(input.account, context, "bluesky.video.upload");
949
+ const name = input.name ?? "video.mp4";
950
+ if ((input.mimeType ?? input.video.type) !== "video/mp4" ||
951
+ input.video.size <= 0 ||
952
+ input.video.size > MAX_VIDEO_BYTES ||
953
+ name.length > 255 ||
954
+ [...name].some((char) => char.charCodeAt(0) < 0x20 || char === "/" || char === "\\"))
955
+ throw new SocialError({
956
+ code: "invalid_input",
957
+ operation: "bluesky.video.upload",
958
+ message: `Bluesky video upload requires a non-empty video/mp4 Blob of at most ${MAX_VIDEO_BYTES} bytes and a plain file name.`,
959
+ retryDisposition: { kind: "never" },
960
+ });
961
+ const token = await serviceToken(await pdsAudience(context), "com.atproto.repo.uploadBlob", VIDEO_UPLOAD_TOKEN_SECONDS, context);
962
+ const response = object(await videoRequest("app.bsky.video.uploadVideo", new URLSearchParams({ did: auth.did, name }), context, { token, body: input.video }));
963
+ try {
964
+ // The lexicon wraps the output in `jobStatus`; the video guide reads it unwrapped.
965
+ return videoJob(response["jobStatus"] ?? response, auth.did);
966
+ }
967
+ catch (error) {
968
+ throw operationError("bluesky.app.bsky.video.uploadVideo", error);
969
+ }
970
+ },
971
+ async getVideoJobStatus(input) {
972
+ // https://docs.bsky.app/docs/api/app-bsky-video-get-job-status
973
+ const context = nativeContext(input.context, "bluesky.video.job");
974
+ assertNativeAccount(input.account, context, "bluesky.video.job");
975
+ if (!/^[\x21-\x7e]{1,256}$/.test(input.jobId))
976
+ throw new SocialError({
977
+ code: "invalid_input",
978
+ operation: "bluesky.video.job",
979
+ message: "Bluesky video job ID is malformed.",
980
+ retryDisposition: { kind: "never" },
981
+ });
982
+ const response = object(await videoRequest("app.bsky.video.getJobStatus", new URLSearchParams({ jobId: input.jobId }), context));
983
+ try {
984
+ return videoJob(response["jobStatus"], auth.did);
985
+ }
986
+ catch (error) {
987
+ throw operationError("bluesky.app.bsky.video.getJobStatus", error, false);
988
+ }
989
+ },
990
+ async getVideoUploadLimits(input) {
991
+ // https://docs.bsky.app/docs/api/app-bsky-video-get-upload-limits
992
+ const context = nativeContext(input.context, "bluesky.video.limits");
993
+ assertNativeAccount(input.account, context, "bluesky.video.limits");
994
+ const token = await serviceToken(`did:web:${videoOrigin.host.replace(":", "%3A")}`, "app.bsky.video.getUploadLimits", undefined, context);
995
+ const response = object(await videoRequest("app.bsky.video.getUploadLimits", new URLSearchParams(), context, {
996
+ token,
997
+ }));
998
+ const canUpload = response["canUpload"];
999
+ if (!isBoolean(canUpload))
1000
+ throw operationError("bluesky.app.bsky.video.getUploadLimits", new HttpError("Upload limits are missing canUpload.", "invalid-response", true), false);
1001
+ const videos = optionalNumber(response["remainingDailyVideos"]);
1002
+ const bytes = optionalNumber(response["remainingDailyBytes"]);
1003
+ const message = optionalString(response["message"]);
1004
+ const error = optionalString(response["error"]);
1005
+ return {
1006
+ canUpload,
1007
+ ...definedFields({
1008
+ remainingDailyVideos: videos,
1009
+ remainingDailyBytes: bytes,
1010
+ message,
1011
+ error,
1012
+ }),
1013
+ };
760
1014
  },
761
1015
  async follow(input) {
762
1016
  const context = nativeContext(input.context, "bluesky.follow");
763
1017
  assertNativeAccount(input.account, context, "bluesky.follow");
764
- // oxlint-disable-next-line anti-slop/require-safety-comment-for-type-assertion -- validated boundary or fixture contract.
765
1018
  return object(await xrpc("com.atproto.repo.createRecord", context, {
766
1019
  body: JSON.stringify({
767
1020
  repo: auth.did,
@@ -788,7 +1041,6 @@ export function bluesky(options) {
788
1041
  async block(input) {
789
1042
  const context = nativeContext(input.context, "bluesky.block");
790
1043
  assertNativeAccount(input.account, context, "bluesky.block");
791
- // oxlint-disable-next-line anti-slop/require-safety-comment-for-type-assertion -- validated boundary or fixture contract.
792
1044
  return object(await xrpc("com.atproto.repo.createRecord", context, {
793
1045
  body: JSON.stringify({
794
1046
  repo: auth.did,
@@ -860,7 +1112,7 @@ export function bluesky(options) {
860
1112
  const value = object(await xrpc(`app.bsky.feed.getLikes?${q}`, context));
861
1113
  return {
862
1114
  likes: array(value["likes"] ?? []).map((entry) => object(entry)),
863
- ...(typeof value["cursor"] === "string" ? { cursor: value["cursor"] } : {}),
1115
+ ...definedFields({ cursor: optionalString(value["cursor"]) }),
864
1116
  };
865
1117
  },
866
1118
  async getActorLikes(input) {
@@ -871,7 +1123,7 @@ export function bluesky(options) {
871
1123
  const value = object(await xrpc(`app.bsky.feed.getActorLikes?${q}`, context));
872
1124
  return {
873
1125
  feed: array(value["feed"] ?? []).map((entry) => object(entry)),
874
- ...(typeof value["cursor"] === "string" ? { cursor: value["cursor"] } : {}),
1126
+ ...definedFields({ cursor: optionalString(value["cursor"]) }),
875
1127
  };
876
1128
  },
877
1129
  async searchActors(input) {
@@ -914,11 +1166,11 @@ export function bluesky(options) {
914
1166
  name: input.name,
915
1167
  purpose: input.purpose,
916
1168
  createdAt: new Date().toISOString(),
917
- ...(input.description === undefined ? {} : { description: input.description }),
1169
+ ...definedFields({ description: input.description }),
918
1170
  };
919
- return postRef(object(await xrpc("com.atproto.repo.createRecord", context, {
1171
+ return postRef(await xrpc("com.atproto.repo.createRecord", context, {
920
1172
  body: JSON.stringify({ repo: auth.did, collection: "app.bsky.graph.list", record }),
921
- })));
1173
+ }));
922
1174
  },
923
1175
  async updateList(input) {
924
1176
  const context = nativeContext(input.context, "bluesky.lists.update");
@@ -929,7 +1181,7 @@ export function bluesky(options) {
929
1181
  ...record,
930
1182
  name: input.name,
931
1183
  purpose: input.purpose,
932
- ...(input.description === undefined ? {} : { description: input.description }),
1184
+ ...definedFields({ description: input.description }),
933
1185
  };
934
1186
  await xrpc("com.atproto.repo.putRecord", context, {
935
1187
  body: JSON.stringify({
@@ -954,7 +1206,7 @@ export function bluesky(options) {
954
1206
  async addListItem(input) {
955
1207
  const context = nativeContext(input.context, "bluesky.lists.items.add");
956
1208
  assertNativeAccount(input.account, context, "bluesky.lists.items.add");
957
- return postRef(object(await xrpc("com.atproto.repo.createRecord", context, {
1209
+ return postRef(await xrpc("com.atproto.repo.createRecord", context, {
958
1210
  body: JSON.stringify({
959
1211
  repo: auth.did,
960
1212
  collection: "app.bsky.graph.listitem",
@@ -965,7 +1217,7 @@ export function bluesky(options) {
965
1217
  createdAt: new Date().toISOString(),
966
1218
  },
967
1219
  }),
968
- })));
1220
+ }));
969
1221
  },
970
1222
  async removeListItem(input) {
971
1223
  const context = nativeContext(input.context, "bluesky.lists.items.remove");
@@ -1031,13 +1283,13 @@ export function bluesky(options) {
1031
1283
  repo: auth.did,
1032
1284
  collection: "app.bsky.graph.listblock",
1033
1285
  limit: "100",
1034
- ...(cursor === undefined ? {} : { cursor }),
1286
+ ...definedFields({ cursor }),
1035
1287
  });
1036
1288
  const records = object(await xrpc(`com.atproto.repo.listRecords?${query.toString()}`, context));
1037
1289
  record = array(records["records"])
1038
1290
  .map((value) => object(value))
1039
1291
  .find((value) => object(value["value"])["subject"] === input.listUri);
1040
- cursor = typeof records["cursor"] === "string" ? records["cursor"] : undefined;
1292
+ cursor = optionalString(records["cursor"]);
1041
1293
  } while (record === undefined && cursor !== undefined);
1042
1294
  if (record === undefined)
1043
1295
  throw new SocialError({
@@ -1059,7 +1311,7 @@ export function bluesky(options) {
1059
1311
  const body = {
1060
1312
  reasonType: input.reasonType,
1061
1313
  subject: input.subject,
1062
- ...(input.reason === undefined ? {} : { reason: input.reason }),
1314
+ ...definedFields({ reason: input.reason }),
1063
1315
  };
1064
1316
  return object(await xrpc("com.atproto.moderation.createReport", context, { body: JSON.stringify(body) }));
1065
1317
  },
@@ -1075,10 +1327,8 @@ export function bluesky(options) {
1075
1327
  });
1076
1328
  const query = new URLSearchParams({
1077
1329
  limit: String(limit),
1078
- // oxlint-disable-next-line anti-slop/no-conditional-empty-object-spread -- validated boundary or fixture contract.
1079
- ...(input.cursor ? { cursor: input.cursor } : {}),
1330
+ ...definedFields({ cursor: input.cursor || undefined }),
1080
1331
  });
1081
- // oxlint-disable-next-line anti-slop/require-safety-comment-for-type-assertion -- validated boundary or fixture contract.
1082
1332
  return object(await xrpc(`app.bsky.notification.listNotifications?${query}`, context));
1083
1333
  },
1084
1334
  async markNotificationsSeen(input) {
@@ -1091,7 +1341,6 @@ export function bluesky(options) {
1091
1341
  async getProfile(input) {
1092
1342
  const context = nativeContext(input.context, "bluesky.profile.get");
1093
1343
  assertNativeAccount(input.account, context, "bluesky.profile.get");
1094
- // oxlint-disable-next-line anti-slop/require-safety-comment-for-type-assertion -- validated boundary or fixture contract.
1095
1344
  return object(await xrpc(`app.bsky.actor.getProfile?actor=${encodeURIComponent(input.actor ?? auth.did)}`, context));
1096
1345
  },
1097
1346
  async updateProfile(input) {
@@ -1118,10 +1367,8 @@ export function bluesky(options) {
1118
1367
  });
1119
1368
  const query = new URLSearchParams({
1120
1369
  limit: String(limit),
1121
- // oxlint-disable-next-line anti-slop/no-conditional-empty-object-spread -- validated boundary or fixture contract.
1122
- ...(input.cursor ? { cursor: input.cursor } : {}),
1370
+ ...definedFields({ cursor: input.cursor || undefined }),
1123
1371
  });
1124
- // oxlint-disable-next-line anti-slop/require-safety-comment-for-type-assertion -- validated boundary or fixture contract.
1125
1372
  return object(await xrpc(`app.bsky.feed.getSuggestedFeeds?${query}`, context));
1126
1373
  },
1127
1374
  async listConversations(input) {
@@ -1136,10 +1383,8 @@ export function bluesky(options) {
1136
1383
  });
1137
1384
  const query = new URLSearchParams({
1138
1385
  limit: String(limit),
1139
- // oxlint-disable-next-line anti-slop/no-conditional-empty-object-spread -- validated boundary or fixture contract.
1140
- ...(input.cursor ? { cursor: input.cursor } : {}),
1386
+ ...definedFields({ cursor: input.cursor || undefined }),
1141
1387
  });
1142
- // oxlint-disable-next-line anti-slop/require-safety-comment-for-type-assertion -- validated boundary or fixture contract.
1143
1388
  return object(await xrpc(`chat.bsky.convo.listConvos?${query}`, context));
1144
1389
  },
1145
1390
  async listMessages(input) {
@@ -1154,10 +1399,8 @@ export function bluesky(options) {
1154
1399
  });
1155
1400
  const query = new URLSearchParams({
1156
1401
  limit: String(limit),
1157
- // oxlint-disable-next-line anti-slop/no-conditional-empty-object-spread -- validated boundary or fixture contract.
1158
- ...(input.cursor ? { cursor: input.cursor } : {}),
1402
+ ...definedFields({ cursor: input.cursor || undefined }),
1159
1403
  });
1160
- // oxlint-disable-next-line anti-slop/require-safety-comment-for-type-assertion -- validated boundary or fixture contract.
1161
1404
  return object(await xrpc(`chat.bsky.convo.getMessages?convoId=${encodeURIComponent(input.conversationId)}&${query}`, context));
1162
1405
  },
1163
1406
  async sendMessage(input) {
@@ -1169,24 +1412,129 @@ export function bluesky(options) {
1169
1412
  operation: "bluesky.chat.send",
1170
1413
  message: "Message text is required.",
1171
1414
  });
1172
- // oxlint-disable-next-line anti-slop/require-safety-comment-for-type-assertion -- validated boundary or fixture contract.
1173
1415
  return object(await xrpc("chat.bsky.convo.sendMessage", context, {
1174
1416
  body: JSON.stringify({ convoId: input.conversationId, message: { text: input.text } }),
1175
1417
  }));
1176
1418
  },
1419
+ async hideReply(input) {
1420
+ const operation = "bluesky.comments.moderate";
1421
+ const context = nativeContext(input.context, operation);
1422
+ assertNativeAccount(input.account, context, operation);
1423
+ const invalid = (message) => new SocialError({
1424
+ code: "invalid_input",
1425
+ operation,
1426
+ message,
1427
+ retryDisposition: { kind: "never" },
1428
+ });
1429
+ const postUri = /^at:\/\/did:[a-z]+:[A-Za-z0-9._:%-]+\/app\.bsky\.feed\.post\/[A-Za-z0-9._~:-]{1,512}$/;
1430
+ if (!postUri.test(input.replyUri))
1431
+ throw invalid("Reply must be an app.bsky.feed.post AT-URI.");
1432
+ const postView = async (uri) => {
1433
+ const query = new URLSearchParams({ uris: uri });
1434
+ const found = array(object(await xrpc(`app.bsky.feed.getPosts?${query.toString()}`, context))["posts"])
1435
+ .map((value) => object(value))
1436
+ .find((value) => value["uri"] === uri);
1437
+ if (found === undefined)
1438
+ throw new SocialError({
1439
+ code: "not_found",
1440
+ operation,
1441
+ message: "Bluesky post was not found.",
1442
+ });
1443
+ return found;
1444
+ };
1445
+ const reply = object(object((await postView(input.replyUri))["record"]))["reply"];
1446
+ if (reply === undefined)
1447
+ throw invalid("The URI identifies a root post, not a reply.");
1448
+ const rootUri = string(object(object(reply)["root"])["uri"]);
1449
+ if (!rootUri.startsWith(`at://${auth.did}/app.bsky.feed.post/`))
1450
+ throw invalid("Only the author of the thread's root post can hide its replies.");
1451
+ const rkey = recordKey(rootUri, "app.bsky.feed.post");
1452
+ const threadgateUri = `at://${auth.did}/app.bsky.feed.threadgate/${rkey}`;
1453
+ const gate = (await postView(rootUri))["threadgate"];
1454
+ let existing;
1455
+ if (gate !== undefined) {
1456
+ const view = object(gate);
1457
+ if (view["uri"] !== threadgateUri)
1458
+ throw new SocialError({
1459
+ code: "upstream_failure",
1460
+ operation,
1461
+ message: "Bluesky returned a threadgate for a different post.",
1462
+ });
1463
+ const record = object(view["record"]);
1464
+ existing = {
1465
+ ref: postRef(view),
1466
+ record,
1467
+ hiddenReplies: record["hiddenReplies"] === undefined
1468
+ ? []
1469
+ : array(record["hiddenReplies"]).map((value) => string(value)),
1470
+ };
1471
+ }
1472
+ const current = existing?.hiddenReplies.includes(input.replyUri) ?? false;
1473
+ if (current === input.hidden)
1474
+ return existing === undefined
1475
+ ? { hidden: current }
1476
+ : { hidden: current, threadgate: existing.ref };
1477
+ if (existing === undefined) {
1478
+ // Omit `allow`: an absent allow list keeps replies open, an empty one closes them.
1479
+ const created = await xrpc("com.atproto.repo.createRecord", context, {
1480
+ body: JSON.stringify({
1481
+ repo: auth.did,
1482
+ collection: "app.bsky.feed.threadgate",
1483
+ rkey,
1484
+ record: {
1485
+ $type: "app.bsky.feed.threadgate",
1486
+ post: rootUri,
1487
+ createdAt: new Date().toISOString(),
1488
+ hiddenReplies: [input.replyUri],
1489
+ },
1490
+ }),
1491
+ });
1492
+ return { hidden: true, threadgate: postRef(created) };
1493
+ }
1494
+ const hiddenReplies = input.hidden
1495
+ ? [...existing.hiddenReplies, input.replyUri]
1496
+ : existing.hiddenReplies.filter((uri) => uri !== input.replyUri);
1497
+ // swapRecord makes the PDS reject the write if the threadgate changed since it was read.
1498
+ const updated = await xrpc("com.atproto.repo.putRecord", context, {
1499
+ body: JSON.stringify({
1500
+ repo: auth.did,
1501
+ collection: "app.bsky.feed.threadgate",
1502
+ rkey,
1503
+ record: { ...existing.record, hiddenReplies },
1504
+ swapRecord: existing.ref.cid,
1505
+ }),
1506
+ });
1507
+ return { hidden: input.hidden, threadgate: postRef(updated) };
1508
+ },
1177
1509
  };
1178
1510
  const adapter = defineAdapter({
1179
1511
  id: backend,
1180
1512
  capabilities,
1181
1513
  native,
1514
+ media: {
1515
+ async upload(media, accountRef, context) {
1516
+ const job = await native.uploadVideo({
1517
+ account: accountRef,
1518
+ video: videoSource(media),
1519
+ mimeType: "video/mp4",
1520
+ ...definedFields({ name: media.filename }),
1521
+ context,
1522
+ });
1523
+ return {
1524
+ kind: "media",
1525
+ version: 1,
1526
+ backend,
1527
+ platform: "bluesky",
1528
+ accountId: auth.did,
1529
+ mediaId: job.jobId,
1530
+ };
1531
+ },
1532
+ },
1182
1533
  graph: {
1183
1534
  async getProfile(account, input, context) {
1184
1535
  const value = await native.getProfile({
1185
1536
  account,
1186
- ...(input.profileId === undefined ? {} : { actor: input.profileId }),
1187
- ...(input.profileId === undefined && input.handle !== undefined
1188
- ? { actor: input.handle }
1189
- : {}),
1537
+ ...definedFields({ actor: input.profileId ?? input.handle }),
1190
1538
  context,
1191
1539
  });
1192
1540
  const actor = object(value);
@@ -1198,42 +1546,39 @@ export function bluesky(options) {
1198
1546
  accountId: account.accountId,
1199
1547
  profileId: id,
1200
1548
  }),
1201
- ...(typeof actor["displayName"] === "string"
1202
- ? { displayName: actor["displayName"] }
1203
- : {}),
1204
- ...(typeof actor["handle"] === "string" ? { handle: actor["handle"] } : {}),
1205
- ...(typeof actor["avatar"] === "string" ? { avatarUrl: actor["avatar"] } : {}),
1206
- ...(typeof actor["description"] === "string" ? { bio: actor["description"] } : {}),
1549
+ ...definedFields({
1550
+ displayName: optionalString(actor["displayName"]),
1551
+ handle: optionalString(actor["handle"]),
1552
+ avatarUrl: optionalString(actor["avatar"]),
1553
+ bio: optionalString(actor["description"]),
1554
+ }),
1207
1555
  native: actor,
1208
1556
  };
1209
1557
  },
1210
1558
  async listRelationships(account, input, context) {
1559
+ const page = definedFields({ cursor: input.cursor, limit: input.limit });
1211
1560
  const result = input.kind === "following"
1212
1561
  ? await native.getFollows({
1213
1562
  account,
1214
1563
  context,
1215
- ...(input.cursor === undefined ? {} : { cursor: input.cursor }),
1216
- ...(input.limit === undefined ? {} : { limit: input.limit }),
1564
+ ...page,
1217
1565
  })
1218
1566
  : input.kind === "followers"
1219
1567
  ? await native.getFollowers({
1220
1568
  account,
1221
1569
  context,
1222
- ...(input.cursor === undefined ? {} : { cursor: input.cursor }),
1223
- ...(input.limit === undefined ? {} : { limit: input.limit }),
1570
+ ...page,
1224
1571
  })
1225
1572
  : input.kind === "blocked"
1226
1573
  ? await native.getBlocks({
1227
1574
  account,
1228
1575
  context,
1229
- ...(input.cursor === undefined ? {} : { cursor: input.cursor }),
1230
- ...(input.limit === undefined ? {} : { limit: input.limit }),
1576
+ ...page,
1231
1577
  })
1232
1578
  : await native.getMutes({
1233
1579
  account,
1234
1580
  context,
1235
- ...(input.cursor === undefined ? {} : { cursor: input.cursor }),
1236
- ...(input.limit === undefined ? {} : { limit: input.limit }),
1581
+ ...page,
1237
1582
  });
1238
1583
  return {
1239
1584
  items: result.actors.map((actor) => {
@@ -1253,7 +1598,7 @@ export function bluesky(options) {
1253
1598
  : input.kind,
1254
1599
  };
1255
1600
  }),
1256
- ...(result.cursor === undefined ? {} : { nextCursor: result.cursor }),
1601
+ ...definedFields({ nextCursor: result.cursor }),
1257
1602
  };
1258
1603
  },
1259
1604
  async follow(target, context) {
@@ -1264,7 +1609,7 @@ export function bluesky(options) {
1264
1609
  const profile = object(await native.getProfile({ account, actor: target.profileId, context }));
1265
1610
  const viewer = object(profile["viewer"] ?? {});
1266
1611
  const uri = viewer["following"];
1267
- if (typeof uri !== "string" || !uri)
1612
+ if (!isString(uri) || !uri)
1268
1613
  throw new SocialError({
1269
1614
  code: "invalid_input",
1270
1615
  operation: "graph.unfollow",
@@ -1280,7 +1625,7 @@ export function bluesky(options) {
1280
1625
  const profile = object(await native.getProfile({ account, actor: target.profileId, context }));
1281
1626
  const viewer = object(profile["viewer"] ?? {});
1282
1627
  const uri = viewer["blocking"];
1283
- if (typeof uri !== "string" || !uri)
1628
+ if (!isString(uri) || !uri)
1284
1629
  throw new SocialError({
1285
1630
  code: "invalid_input",
1286
1631
  operation: "graph.unblock",
@@ -1304,8 +1649,7 @@ export function bluesky(options) {
1304
1649
  {
1305
1650
  ref: account,
1306
1651
  displayName: session.handle ?? session.did,
1307
- // oxlint-disable-next-line anti-slop/no-conditional-empty-object-spread -- validated boundary or fixture contract.
1308
- ...(session.handle === undefined ? {} : { handle: session.handle }),
1652
+ ...definedFields({ handle: session.handle }),
1309
1653
  status: "connected",
1310
1654
  },
1311
1655
  ],
@@ -1323,8 +1667,7 @@ export function bluesky(options) {
1323
1667
  return {
1324
1668
  ref: account,
1325
1669
  displayName: session.handle ?? session.did,
1326
- // oxlint-disable-next-line anti-slop/no-conditional-empty-object-spread -- validated boundary or fixture contract.
1327
- ...(session.handle === undefined ? {} : { handle: session.handle }),
1670
+ ...definedFields({ handle: session.handle }),
1328
1671
  status: "connected",
1329
1672
  };
1330
1673
  },
@@ -1341,17 +1684,21 @@ export function bluesky(options) {
1341
1684
  const result = await native.searchPosts({
1342
1685
  account,
1343
1686
  query: input.query,
1344
- ...(input.cursor === undefined ? {} : { cursor: input.cursor }),
1345
- ...(input.limit === undefined ? {} : { limit: input.limit }),
1346
- ...(input.startTime === undefined ? {} : { since: input.startTime }),
1347
- ...(input.endTime === undefined ? {} : { until: input.endTime }),
1348
- ...(input.scope === undefined ? {} : { scope: input.scope }),
1687
+ ...definedFields({
1688
+ cursor: input.cursor,
1689
+ limit: input.limit,
1690
+ since: input.startTime,
1691
+ until: input.endTime,
1692
+ scope: input.scope,
1693
+ }),
1349
1694
  context,
1350
1695
  });
1351
1696
  return {
1352
1697
  items: result.posts,
1353
- ...(result.cursor === undefined ? {} : { nextCursor: result.cursor }),
1354
- ...(result.hitsTotal === undefined ? {} : { metadata: { hitsTotal: result.hitsTotal } }),
1698
+ ...definedFields({
1699
+ nextCursor: result.cursor,
1700
+ metadata: result.hitsTotal === undefined ? undefined : { hitsTotal: result.hitsTotal },
1701
+ }),
1355
1702
  };
1356
1703
  },
1357
1704
  },
@@ -1366,24 +1713,14 @@ export function bluesky(options) {
1366
1713
  });
1367
1714
  const response = await native.listNotifications({
1368
1715
  account,
1369
- // oxlint-disable-next-line anti-slop/no-conditional-empty-object-spread -- validated boundary or fixture contract.
1370
- ...(input.cursor === undefined ? {} : { cursor: input.cursor }),
1371
- // oxlint-disable-next-line anti-slop/no-conditional-empty-object-spread -- validated boundary or fixture contract.
1372
- ...(input.limit === undefined ? {} : { limit: input.limit }),
1716
+ ...definedFields({ cursor: input.cursor, limit: input.limit }),
1373
1717
  context,
1374
1718
  });
1375
1719
  const values = response["notifications"];
1376
- const items = values === undefined
1377
- ? []
1378
- : array(values).map((value) => {
1379
- // oxlint-disable-next-line anti-slop/require-safety-comment-for-type-assertion -- validated provider notification object.
1380
- return object(value);
1381
- });
1382
- const cursor = response["cursor"];
1720
+ const items = values === undefined ? [] : array(values).map((value) => object(value));
1383
1721
  return {
1384
1722
  items,
1385
- // oxlint-disable-next-line anti-slop/no-runtime-typeof -- validated boundary or fixture contract.
1386
- ...(typeof cursor === "string" ? { nextCursor: cursor } : {}),
1723
+ ...definedFields({ nextCursor: optionalString(response["cursor"]) }),
1387
1724
  };
1388
1725
  },
1389
1726
  async markSeen(account, input, context) {
@@ -1396,8 +1733,7 @@ export function bluesky(options) {
1396
1733
  });
1397
1734
  await native.markNotificationsSeen({
1398
1735
  account,
1399
- // oxlint-disable-next-line anti-slop/no-conditional-empty-object-spread -- validated boundary or fixture contract.
1400
- ...(input.seenAt === undefined ? {} : { seenAt: input.seenAt }),
1736
+ ...definedFields({ seenAt: input.seenAt }),
1401
1737
  context,
1402
1738
  });
1403
1739
  },
@@ -1420,8 +1756,7 @@ export function bluesky(options) {
1420
1756
  const query = new URLSearchParams({
1421
1757
  actor: auth.did,
1422
1758
  limit: String(limit),
1423
- // oxlint-disable-next-line anti-slop/no-conditional-empty-object-spread -- validated boundary or fixture contract.
1424
- ...(input.cursor === undefined ? {} : { cursor: input.cursor }),
1759
+ ...definedFields({ cursor: input.cursor }),
1425
1760
  });
1426
1761
  const response = object(await xrpc(`app.bsky.feed.getAuthorFeed?${query.toString()}`, context));
1427
1762
  const feed = array(response["feed"]).map((value) => {
@@ -1438,53 +1773,45 @@ export function bluesky(options) {
1438
1773
  "indexedAt",
1439
1774
  "labels",
1440
1775
  ]),
1441
- // oxlint-disable-next-line anti-slop/no-conditional-empty-object-spread -- validated boundary or fixture contract.
1442
- ...(post["author"] === undefined
1443
- ? {}
1444
- : {
1445
- author: publicFields(object(post["author"]), [
1776
+ ...definedFields({
1777
+ author: post["author"] === undefined
1778
+ ? undefined
1779
+ : publicFields(object(post["author"]), [
1446
1780
  "did",
1447
1781
  "handle",
1448
1782
  "displayName",
1449
1783
  "avatar",
1450
1784
  ]),
1451
- }),
1452
- // oxlint-disable-next-line anti-slop/no-conditional-empty-object-spread -- validated boundary or fixture contract.
1453
- ...(post["record"] === undefined
1454
- ? {}
1455
- : {
1456
- record: publicFields(object(post["record"]), [
1785
+ record: post["record"] === undefined
1786
+ ? undefined
1787
+ : publicFields(object(post["record"]), [
1457
1788
  "text",
1458
1789
  "facets",
1459
1790
  "createdAt",
1460
1791
  "reply",
1461
1792
  "embed",
1462
1793
  ]),
1463
- }),
1794
+ }),
1464
1795
  };
1465
- // oxlint-disable-next-line anti-slop/require-safety-comment-for-type-assertion -- validated boundary or fixture contract.
1466
1796
  return {
1467
1797
  post: safePost,
1468
- // oxlint-disable-next-line anti-slop/no-conditional-empty-object-spread -- validated boundary or fixture contract.
1469
- ...(entry["reason"] === undefined
1470
- ? {}
1471
- : { reason: publicFields(object(entry["reason"]), ["$type", "by", "indexedAt"]) }),
1798
+ ...definedFields({
1799
+ reason: entry["reason"] === undefined
1800
+ ? undefined
1801
+ : publicFields(object(entry["reason"]), ["$type", "by", "indexedAt"]),
1802
+ }),
1472
1803
  };
1473
1804
  });
1474
- // oxlint-disable-next-line anti-slop/no-runtime-typeof -- validated boundary or fixture contract.
1475
- const cursor = typeof response["cursor"] === "string" ? response["cursor"] : undefined;
1476
1805
  return {
1477
- // oxlint-disable-next-line anti-slop/require-safety-comment-for-type-assertion -- validated boundary or fixture contract.
1478
1806
  items: feed,
1479
- // oxlint-disable-next-line anti-slop/no-conditional-empty-object-spread -- validated boundary or fixture contract.
1480
- ...(cursor === undefined ? {} : { nextCursor: cursor }),
1807
+ ...definedFields({ nextCursor: optionalString(response["cursor"]) }),
1481
1808
  };
1482
1809
  },
1483
1810
  prepareTarget(target) {
1484
1811
  const issues = [];
1485
1812
  const text = target.content.text ?? "";
1486
1813
  try {
1487
- richTextOptions(text, target.options);
1814
+ richTextOptions(text, target);
1488
1815
  }
1489
1816
  catch (error) {
1490
1817
  issues.push({
@@ -1511,13 +1838,46 @@ export function bluesky(options) {
1511
1838
  message: "Bluesky scheduling requires an application-owned job runner.",
1512
1839
  severity: "error",
1513
1840
  });
1514
- if (target.content.media?.some((media) => media.kind !== "image"))
1841
+ const media = target.content.media ?? [];
1842
+ const videos = media.filter((item) => item.kind === "video");
1843
+ if (media.some((item) => item.kind === "document"))
1515
1844
  issues.push({
1516
- code: "media.unsupported",
1517
- message: "Bluesky direct publishing supports images, not video.",
1845
+ code: "media.unsupported_kind",
1846
+ message: "Bluesky posts accept image and video media only.",
1518
1847
  severity: "error",
1519
1848
  });
1520
- if ((target.content.media?.length ?? 0) > MAX_IMAGES)
1849
+ if (videos.length > 0 && media.length !== 1)
1850
+ issues.push({
1851
+ code: "media.mixed",
1852
+ message: "A Bluesky post carries either up to four images or exactly one video.",
1853
+ severity: "error",
1854
+ });
1855
+ for (const video of videos) {
1856
+ const ref = video.source.kind === "media-ref" ? video.source.ref : undefined;
1857
+ if (ref === undefined)
1858
+ issues.push({
1859
+ code: "media.video_ref_required",
1860
+ message: "Upload the video with media.upload, wait until its job has a blob, then publish the returned media reference.",
1861
+ severity: "error",
1862
+ });
1863
+ else if (ref.backend !== target.account.backend ||
1864
+ ref.platform !== "bluesky" ||
1865
+ ref.accountId !== target.account.accountId ||
1866
+ !/^[\x21-\x7e]{1,256}$/.test(ref.mediaId))
1867
+ issues.push({
1868
+ code: "media.video_owner",
1869
+ message: "Video reference belongs to another account or backend, or is malformed.",
1870
+ severity: "error",
1871
+ });
1872
+ if ((video.width === undefined) !== (video.height === undefined) ||
1873
+ [video.width, video.height].some((value) => value !== undefined && (!Number.isSafeInteger(value) || value < 1)))
1874
+ issues.push({
1875
+ code: "media.aspect_ratio",
1876
+ message: "Video width and height must be supplied together as positive integers.",
1877
+ severity: "error",
1878
+ });
1879
+ }
1880
+ if (videos.length === 0 && media.length > MAX_IMAGES)
1521
1881
  issues.push({
1522
1882
  code: "media.too_many",
1523
1883
  message: `Bluesky supports at most ${MAX_IMAGES} images per post.`,
@@ -1536,20 +1896,10 @@ export function bluesky(options) {
1536
1896
  try {
1537
1897
  const content = target.content;
1538
1898
  const text = content.text ?? "";
1539
- // oxlint-disable-next-line anti-slop/no-known-value-widening -- validated boundary or fixture contract.
1540
- // oxlint-disable-next-line anti-slop/no-unsafe-dictionary-type -- validated boundary or fixture contract.
1541
- // oxlint-disable-next-line anti-slop/no-known-value-widening -- provider payload is validated at this adapter boundary.
1542
- // oxlint-disable-next-line anti-slop/no-unsafe-dictionary-type -- validated external boundary or fixture contract.
1543
- // oxlint-disable-next-line anti-slop/no-known-value-widening -- validated external boundary or fixture contract.
1544
- // oxlint-disable-next-line anti-slop/no-unsafe-dictionary-type -- validated external boundary or fixture contract.
1545
- // oxlint-disable-next-line anti-slop/no-known-value-widening -- validated external boundary or fixture contract.
1546
- // oxlint-disable-next-line anti-slop/no-unsafe-dictionary-type -- validated external boundary or fixture contract.
1547
- const record = {
1548
- $type: "app.bsky.feed.post",
1549
- text,
1550
- createdAt: new Date().toISOString(),
1551
- };
1552
- Object.assign(record, richTextOptions(text, target.options));
1899
+ const createdAt = new Date().toISOString();
1900
+ const richText = richTextOptions(text, target);
1901
+ let reply;
1902
+ let embed;
1553
1903
  if (target.replyTo !== undefined) {
1554
1904
  if (target.replyTo.kind !== "platform-post")
1555
1905
  throw new SocialError({
@@ -1570,32 +1920,85 @@ export function bluesky(options) {
1570
1920
  };
1571
1921
  const rootValue = parentRecord["reply"];
1572
1922
  const root = rootValue === undefined ? parentRef : object(object(rootValue)["root"]);
1573
- Object.assign(record, { reply: { root, parent: parentRef } });
1923
+ reply = { root, parent: parentRef };
1924
+ }
1925
+ const video = content.media?.find((item) => item.kind === "video");
1926
+ if (video !== undefined) {
1927
+ if (video.source.kind !== "media-ref" || content.media?.length !== 1)
1928
+ throw new SocialError({
1929
+ code: "invalid_input",
1930
+ operation: "bluesky.publish",
1931
+ message: "Bluesky video posts require exactly one uploaded video reference.",
1932
+ retryDisposition: { kind: "never" },
1933
+ });
1934
+ const ref = video.source.ref;
1935
+ if (ref.backend !== backend || ref.platform !== "bluesky" || ref.accountId !== auth.did)
1936
+ throw new SocialError({
1937
+ code: "unauthorized",
1938
+ operation: "bluesky.publish",
1939
+ message: "The video reference does not belong to this adapter.",
1940
+ account: target.account,
1941
+ });
1942
+ // One status read. A job that is still processing fails before any post is created.
1943
+ const job = await native.getVideoJobStatus({
1944
+ account: target.account,
1945
+ jobId: ref.mediaId,
1946
+ context,
1947
+ });
1948
+ if (job.blob === undefined)
1949
+ throw new SocialError(job.state === "JOB_STATE_FAILED"
1950
+ ? {
1951
+ code: "media_error",
1952
+ operation: "bluesky.publish",
1953
+ message: "Bluesky video processing failed. No post was created.",
1954
+ retryDisposition: { kind: "never" },
1955
+ ...definedFields({ upstreamCode: job.failureCode }),
1956
+ }
1957
+ : {
1958
+ code: "media_error",
1959
+ operation: "bluesky.publish",
1960
+ message: "Bluesky video processing is not complete. No post was created. Check native.getVideoJobStatus, then publish again.",
1961
+ retryDisposition: { kind: "after-delay", delayMs: 1000 },
1962
+ upstreamCode: job.state,
1963
+ });
1964
+ embed = {
1965
+ $type: "app.bsky.embed.video",
1966
+ video: job.blob,
1967
+ ...definedFields({
1968
+ alt: video.altText,
1969
+ aspectRatio: video.width === undefined || video.height === undefined
1970
+ ? undefined
1971
+ : { width: video.width, height: video.height },
1972
+ }),
1973
+ };
1574
1974
  }
1575
- if (content.media !== undefined && content.media.length > 0) {
1576
- // oxlint-disable-next-line anti-slop/no-unsafe-dictionary-type -- validated boundary or fixture contract.
1975
+ else if (content.media !== undefined && content.media.length > 0) {
1577
1976
  const blobs = [];
1578
1977
  for (const media of content.media) {
1579
1978
  const source = await readMedia(media.source, fetcher, context, allowMediaHost);
1580
1979
  const blob = object(await xrpc("com.atproto.repo.uploadBlob", context, {
1581
- // oxlint-disable-next-line anti-slop/require-safety-comment-for-type-assertion -- validated boundary or fixture contract.
1582
- body: new Blob([source.bytes.slice().buffer], {
1980
+ body: new Blob([source.bytes.slice()], {
1583
1981
  type: source.mimeType,
1584
1982
  }),
1585
1983
  headers: { "Content-Type": source.mimeType },
1586
1984
  }));
1587
1985
  blobs.push(object(blob["blob"]));
1588
1986
  }
1589
- Object.assign(record, {
1590
- embed: {
1591
- $type: "app.bsky.embed.images",
1592
- images: blobs.map((blob, index) => ({
1593
- image: blob,
1594
- alt: content.media?.[index]?.altText ?? "",
1595
- })),
1596
- },
1597
- });
1987
+ embed = {
1988
+ $type: "app.bsky.embed.images",
1989
+ images: blobs.map((blob, index) => ({
1990
+ image: blob,
1991
+ alt: content.media?.[index]?.altText ?? "",
1992
+ })),
1993
+ };
1598
1994
  }
1995
+ const record = {
1996
+ $type: "app.bsky.feed.post",
1997
+ text,
1998
+ createdAt,
1999
+ ...richText,
2000
+ ...definedFields({ reply, embed }),
2001
+ };
1599
2002
  const response = object(await xrpc("com.atproto.repo.createRecord", context, {
1600
2003
  body: JSON.stringify({ repo: auth.did, collection: "app.bsky.feed.post", record }),
1601
2004
  headers: { "Content-Type": "application/json" },
@@ -1670,7 +2073,6 @@ export function bluesky(options) {
1670
2073
  });
1671
2074
  const thread = await native.getPostThread({ uri: post.postId, context });
1672
2075
  const replies = array(object(thread["thread"])["replies"] ?? []);
1673
- // oxlint-disable-next-line anti-slop/require-safety-comment-for-type-assertion -- validated boundary or fixture contract.
1674
2076
  return { items: replies.map((reply) => object(reply)) };
1675
2077
  },
1676
2078
  async reply(comment, content, context) {
@@ -1720,8 +2122,7 @@ export function bluesky(options) {
1720
2122
  ["quotes", "quoteCount"],
1721
2123
  ]) {
1722
2124
  const count = record[field];
1723
- // oxlint-disable-next-line anti-slop/no-runtime-typeof -- validated boundary or fixture contract.
1724
- if (typeof count === "number" && Number.isFinite(count))
2125
+ if (isFiniteNumber(count))
1725
2126
  metrics.push({
1726
2127
  name,
1727
2128
  value: count,
@@ -1754,9 +2155,7 @@ export function bluesky(options) {
1754
2155
  ["followers", profile["followersCount"]],
1755
2156
  ["following", profile["followsCount"]],
1756
2157
  ["posts", profile["postsCount"]],
1757
- ].flatMap(([name, value]) =>
1758
- // oxlint-disable-next-line anti-slop/no-runtime-typeof -- validated boundary or fixture contract.
1759
- typeof value === "number" && Number.isFinite(value)
2158
+ ].flatMap(([name, value]) => isFiniteNumber(value)
1760
2159
  ? [
1761
2160
  {
1762
2161
  name,