@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.
- package/dist/cli-request.d.ts +15 -0
- package/dist/cli-request.js +193 -0
- package/dist/cli.d.ts +4 -3
- package/dist/cli.js +24 -21
- package/dist/cloud/common.d.ts +13 -8
- package/dist/cloud/common.js +40 -60
- package/dist/cloud/lifecycle.js +31 -35
- package/dist/cloud/media.d.ts +6 -2
- package/dist/cloud/media.js +50 -37
- package/dist/cloud/outcomes.d.ts +4 -3
- package/dist/cloud/outcomes.js +8 -15
- package/dist/cloud/post-for-me.js +41 -49
- package/dist/cloud/postfast.d.ts +54 -0
- package/dist/cloud/postfast.js +578 -0
- package/dist/cloud/zernio.js +58 -98
- package/dist/core/client.js +79 -99
- package/dist/core/fields.d.ts +14 -0
- package/dist/core/fields.js +14 -0
- package/dist/core/idempotency.d.ts +7 -2
- package/dist/core/idempotency.js +37 -20
- package/dist/core/pagination.js +8 -7
- package/dist/core/types.d.ts +3 -2
- package/dist/platforms/bluesky.d.ts +65 -1
- package/dist/platforms/bluesky.js +675 -276
- package/dist/platforms/instagram.d.ts +2 -0
- package/dist/platforms/instagram.js +130 -105
- package/dist/platforms/linkedin.d.ts +58 -1
- package/dist/platforms/linkedin.js +877 -107
- package/dist/platforms/threads.d.ts +13 -1
- package/dist/platforms/threads.js +204 -302
- package/dist/platforms/tiktok.d.ts +4 -0
- package/dist/platforms/tiktok.js +140 -124
- package/dist/platforms/webhook-adapter.d.ts +9 -0
- package/dist/platforms/webhook-adapter.js +24 -0
- package/dist/platforms/x-engagement.js +7 -12
- package/dist/platforms/x-stream.d.ts +83 -0
- package/dist/platforms/x-stream.js +350 -0
- package/dist/platforms/x.d.ts +72 -0
- package/dist/platforms/x.js +328 -119
- package/dist/platforms/youtube-upload.d.ts +1 -1
- package/dist/platforms/youtube-upload.js +6 -2
- package/dist/platforms/youtube.d.ts +28 -4
- package/dist/platforms/youtube.js +291 -133
- package/dist/server/bluesky-oauth.d.ts +177 -0
- package/dist/server/bluesky-oauth.js +1229 -0
- package/dist/server/connections.d.ts +14 -0
- package/dist/server/connections.js +10 -2
- package/dist/server/egress.d.ts +14 -0
- package/dist/server/egress.js +115 -0
- package/dist/server/oauth-internal.d.ts +6 -0
- package/dist/server/oauth-internal.js +66 -0
- package/dist/server/oauth.d.ts +1 -1
- package/dist/server/oauth.js +46 -99
- package/dist/server/webhooks.d.ts +136 -3
- package/dist/server/webhooks.js +639 -25
- package/dist/testing/index.js +14 -28
- package/dist/transport/http.d.ts +1 -1
- package/dist/transport/http.js +0 -1
- package/dist/transport/json.d.ts +7 -0
- package/dist/transport/json.js +32 -4
- package/dist/transport/upload.d.ts +1 -1
- package/dist/transport/upload.js +46 -38
- package/dist/transport/validation.d.ts +16 -5
- package/dist/transport/validation.js +29 -7
- package/package.json +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
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
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
|
-
|
|
224
|
+
invalidRichText("A supplied Bluesky option is not supported.");
|
|
123
225
|
let langs;
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
if (!
|
|
127
|
-
|
|
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
|
-
|
|
133
|
-
langs = Intl.getCanonicalLocales(languages);
|
|
231
|
+
langs = Intl.getCanonicalLocales([...languages]);
|
|
134
232
|
}
|
|
135
233
|
catch {
|
|
136
|
-
|
|
234
|
+
invalidRichText("Bluesky languages must be valid BCP 47 tags.");
|
|
137
235
|
}
|
|
138
236
|
}
|
|
139
|
-
const facets =
|
|
237
|
+
const facets = linkFacets(text);
|
|
140
238
|
const bytes = new TextEncoder().encode(text);
|
|
141
|
-
const mentions =
|
|
142
|
-
if (!
|
|
143
|
-
|
|
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
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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: "
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
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
|
-
...(
|
|
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
|
-
...(
|
|
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
|
-
|
|
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
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
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
|
-
|
|
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(
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
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
|
-
...(
|
|
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
|
-
...(
|
|
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
|
-
...(
|
|
1169
|
+
...definedFields({ description: input.description }),
|
|
918
1170
|
};
|
|
919
|
-
return postRef(
|
|
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
|
-
...(
|
|
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(
|
|
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
|
-
...(
|
|
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 =
|
|
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
|
-
...(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
...(
|
|
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
|
-
...(
|
|
1202
|
-
|
|
1203
|
-
:
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
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
|
-
...
|
|
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
|
-
...
|
|
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
|
-
...
|
|
1230
|
-
...(input.limit === undefined ? {} : { limit: input.limit }),
|
|
1576
|
+
...page,
|
|
1231
1577
|
})
|
|
1232
1578
|
: await native.getMutes({
|
|
1233
1579
|
account,
|
|
1234
1580
|
context,
|
|
1235
|
-
...
|
|
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
|
-
...(
|
|
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 (
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
...(
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
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
|
-
...(
|
|
1354
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1442
|
-
|
|
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
|
-
|
|
1453
|
-
|
|
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
|
-
|
|
1469
|
-
|
|
1470
|
-
|
|
1471
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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.
|
|
1517
|
-
message: "Bluesky
|
|
1845
|
+
code: "media.unsupported_kind",
|
|
1846
|
+
message: "Bluesky posts accept image and video media only.",
|
|
1518
1847
|
severity: "error",
|
|
1519
1848
|
});
|
|
1520
|
-
if (
|
|
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
|
-
|
|
1540
|
-
|
|
1541
|
-
|
|
1542
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1590
|
-
|
|
1591
|
-
|
|
1592
|
-
|
|
1593
|
-
|
|
1594
|
-
|
|
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
|
-
|
|
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,
|