@opencoredev/social-sdk 0.1.2 → 0.2.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/README.md +6 -0
- package/dist/cli.d.ts +14 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +235 -0
- package/dist/cli.js.map +1 -0
- package/dist/cloud/common.d.ts +26 -0
- package/dist/cloud/common.d.ts.map +1 -0
- package/dist/cloud/common.js +335 -0
- package/dist/cloud/common.js.map +1 -0
- package/dist/cloud/lifecycle.d.ts +11 -0
- package/dist/cloud/lifecycle.d.ts.map +1 -0
- package/dist/cloud/lifecycle.js +113 -0
- package/dist/cloud/lifecycle.js.map +1 -0
- package/dist/cloud/media.d.ts +24 -0
- package/dist/cloud/media.d.ts.map +1 -0
- package/dist/cloud/media.js +101 -0
- package/dist/cloud/media.js.map +1 -0
- package/dist/cloud/outcomes.d.ts +10 -0
- package/dist/cloud/outcomes.d.ts.map +1 -0
- package/dist/cloud/outcomes.js +196 -0
- package/dist/cloud/outcomes.js.map +1 -0
- package/dist/cloud/post-for-me.d.ts +70 -0
- package/dist/cloud/post-for-me.d.ts.map +1 -0
- package/dist/cloud/post-for-me.js +397 -0
- package/dist/cloud/post-for-me.js.map +1 -0
- package/dist/cloud/zernio.d.ts +112 -0
- package/dist/cloud/zernio.d.ts.map +1 -0
- package/dist/cloud/zernio.js +633 -0
- package/dist/cloud/zernio.js.map +1 -0
- package/dist/core/adapter.d.ts +159 -0
- package/dist/core/adapter.d.ts.map +1 -0
- package/dist/core/adapter.js +4 -0
- package/dist/core/adapter.js.map +1 -0
- package/dist/core/client.d.ts +142 -0
- package/dist/core/client.d.ts.map +1 -0
- package/dist/core/client.js +1286 -0
- package/dist/core/client.js.map +1 -0
- package/dist/core/concurrency.d.ts +10 -0
- package/dist/core/concurrency.d.ts.map +1 -0
- package/dist/core/concurrency.js +80 -0
- package/dist/core/concurrency.js.map +1 -0
- package/dist/core/errors.d.ts +45 -0
- package/dist/core/errors.d.ts.map +1 -0
- package/dist/core/errors.js +51 -0
- package/dist/core/errors.js.map +1 -0
- package/dist/core/idempotency.d.ts +34 -0
- package/dist/core/idempotency.d.ts.map +1 -0
- package/dist/core/idempotency.js +59 -0
- package/dist/core/idempotency.js.map +1 -0
- package/dist/core/index.d.ts +7 -0
- package/dist/core/index.d.ts.map +1 -0
- package/dist/core/index.js +7 -0
- package/dist/core/index.js.map +1 -0
- package/dist/core/pagination.d.ts +12 -0
- package/dist/core/pagination.d.ts.map +1 -0
- package/dist/core/pagination.js +82 -0
- package/dist/core/pagination.js.map +1 -0
- package/dist/core/types.d.ts +397 -0
- package/dist/core/types.d.ts.map +1 -0
- package/dist/core/types.js +17 -0
- package/dist/core/types.js.map +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -0
- package/dist/platforms/bluesky.d.ts +262 -0
- package/dist/platforms/bluesky.d.ts.map +1 -0
- package/dist/platforms/bluesky.js +1777 -0
- package/dist/platforms/bluesky.js.map +1 -0
- package/dist/platforms/instagram.d.ts +130 -0
- package/dist/platforms/instagram.d.ts.map +1 -0
- package/dist/platforms/instagram.js +1032 -0
- package/dist/platforms/instagram.js.map +1 -0
- package/dist/platforms/linkedin.d.ts +109 -0
- package/dist/platforms/linkedin.d.ts.map +1 -0
- package/dist/platforms/linkedin.js +891 -0
- package/dist/platforms/linkedin.js.map +1 -0
- package/dist/platforms/threads.d.ts +135 -0
- package/dist/platforms/threads.d.ts.map +1 -0
- package/dist/platforms/threads.js +946 -0
- package/dist/platforms/threads.js.map +1 -0
- package/dist/platforms/tiktok.d.ts +32 -0
- package/dist/platforms/tiktok.d.ts.map +1 -0
- package/dist/platforms/tiktok.js +594 -0
- package/dist/platforms/tiktok.js.map +1 -0
- package/dist/platforms/x-engagement.d.ts +17 -0
- package/dist/platforms/x-engagement.d.ts.map +1 -0
- package/dist/platforms/x-engagement.js +51 -0
- package/dist/platforms/x-engagement.js.map +1 -0
- package/dist/platforms/x.d.ts +300 -0
- package/dist/platforms/x.d.ts.map +1 -0
- package/dist/platforms/x.js +1288 -0
- package/dist/platforms/x.js.map +1 -0
- package/dist/platforms/youtube-upload.d.ts +33 -0
- package/dist/platforms/youtube-upload.d.ts.map +1 -0
- package/dist/platforms/youtube-upload.js +297 -0
- package/dist/platforms/youtube-upload.js.map +1 -0
- package/dist/platforms/youtube.d.ts +109 -0
- package/dist/platforms/youtube.d.ts.map +1 -0
- package/dist/platforms/youtube.js +1101 -0
- package/dist/platforms/youtube.js.map +1 -0
- package/dist/server/connections.d.ts +124 -0
- package/dist/server/connections.d.ts.map +1 -0
- package/dist/server/connections.js +336 -0
- package/dist/server/connections.js.map +1 -0
- package/dist/server/credentials.d.ts +52 -0
- package/dist/server/credentials.d.ts.map +1 -0
- package/dist/server/credentials.js +109 -0
- package/dist/server/credentials.js.map +1 -0
- package/dist/server/index.d.ts +5 -0
- package/dist/server/index.d.ts.map +1 -0
- package/dist/server/index.js +5 -0
- package/dist/server/index.js.map +1 -0
- package/dist/server/oauth.d.ts +53 -0
- package/dist/server/oauth.d.ts.map +1 -0
- package/dist/server/oauth.js +554 -0
- package/dist/server/oauth.js.map +1 -0
- package/dist/server/webhooks.d.ts +58 -0
- package/dist/server/webhooks.d.ts.map +1 -0
- package/dist/server/webhooks.js +205 -0
- package/dist/server/webhooks.js.map +1 -0
- package/dist/testing/index.d.ts +47 -0
- package/dist/testing/index.d.ts.map +1 -0
- package/dist/testing/index.js +565 -0
- package/dist/testing/index.js.map +1 -0
- package/dist/testing.d.ts +2 -0
- package/dist/testing.d.ts.map +1 -0
- package/dist/testing.js +2 -0
- package/dist/testing.js.map +1 -0
- package/dist/transport/binary.d.ts +3 -0
- package/dist/transport/binary.d.ts.map +1 -0
- package/dist/transport/binary.js +30 -0
- package/dist/transport/binary.js.map +1 -0
- package/dist/transport/budget.d.ts +4 -0
- package/dist/transport/budget.d.ts.map +1 -0
- package/dist/transport/budget.js +27 -0
- package/dist/transport/budget.js.map +1 -0
- package/dist/transport/http.d.ts +45 -0
- package/dist/transport/http.d.ts.map +1 -0
- package/dist/transport/http.js +260 -0
- package/dist/transport/http.js.map +1 -0
- package/dist/transport/json.d.ts +9 -0
- package/dist/transport/json.d.ts.map +1 -0
- package/dist/transport/json.js +17 -0
- package/dist/transport/json.js.map +1 -0
- package/dist/transport/upload.d.ts +26 -0
- package/dist/transport/upload.d.ts.map +1 -0
- package/dist/transport/upload.js +154 -0
- package/dist/transport/upload.js.map +1 -0
- package/dist/transport/validation.d.ts +6 -0
- package/dist/transport/validation.d.ts.map +1 -0
- package/dist/transport/validation.js +25 -0
- package/dist/transport/validation.js.map +1 -0
- package/package.json +2 -1
|
@@ -0,0 +1,1777 @@
|
|
|
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
|
+
import { readBinary } from "../transport/binary.js";
|
|
3
|
+
import { remainingBudget } from "../transport/budget.js";
|
|
4
|
+
import { connectedAccountRef, defineAdapter, platformPostRef, profileRef, } from "../core/index.js";
|
|
5
|
+
import { SocialError } from "../core/errors.js";
|
|
6
|
+
import { abortable, createHttp, HttpError } from "../transport/http.js";
|
|
7
|
+
import { httpsUrl } from "../transport/upload.js";
|
|
8
|
+
import { array, object, string } from "../transport/validation.js";
|
|
9
|
+
import { publicFields } from "../cloud/common.js";
|
|
10
|
+
const MAX_IMAGE_BYTES = 10 * 1024 * 1024;
|
|
11
|
+
const MAX_IMAGES = 4;
|
|
12
|
+
function endpoint(service, method) {
|
|
13
|
+
let base;
|
|
14
|
+
try {
|
|
15
|
+
base = new URL(service);
|
|
16
|
+
}
|
|
17
|
+
catch {
|
|
18
|
+
throw new SocialError({
|
|
19
|
+
code: "invalid_config",
|
|
20
|
+
operation: "bluesky.configure",
|
|
21
|
+
message: "Bluesky service must be an absolute URL.",
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
if (base.protocol !== "https:" || base.username || base.password) {
|
|
25
|
+
throw new SocialError({
|
|
26
|
+
code: "invalid_config",
|
|
27
|
+
operation: "bluesky.configure",
|
|
28
|
+
message: "Bluesky service must use HTTPS without embedded credentials.",
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
return new URL(`/xrpc/${method}`, base);
|
|
32
|
+
}
|
|
33
|
+
function accountMatches(ref, auth, backend) {
|
|
34
|
+
return ref.backend === backend && ref.platform === "bluesky" && ref.accountId === auth.did;
|
|
35
|
+
}
|
|
36
|
+
function authHeaders(auth) {
|
|
37
|
+
return auth.accessJwt === undefined ? {} : { Authorization: `Bearer ${auth.accessJwt}` };
|
|
38
|
+
}
|
|
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
|
+
const ambiguous = mutation &&
|
|
45
|
+
error.dispatched &&
|
|
46
|
+
(error.kind !== "http" || (error.status !== undefined && error.status >= 500));
|
|
47
|
+
const code = ambiguous
|
|
48
|
+
? "ambiguous_outcome"
|
|
49
|
+
: error.kind === "timeout"
|
|
50
|
+
? "timeout"
|
|
51
|
+
: error.kind === "cancelled"
|
|
52
|
+
? "cancelled"
|
|
53
|
+
: error.status === 401
|
|
54
|
+
? "reconnect_required"
|
|
55
|
+
: error.status === 403
|
|
56
|
+
? "missing_permission"
|
|
57
|
+
: error.status === 404
|
|
58
|
+
? "not_found"
|
|
59
|
+
: error.status === 429
|
|
60
|
+
? "rate_limited"
|
|
61
|
+
: "upstream_failure";
|
|
62
|
+
return new SocialError({
|
|
63
|
+
code,
|
|
64
|
+
operation,
|
|
65
|
+
message: error.message,
|
|
66
|
+
retryDisposition: ambiguous
|
|
67
|
+
? { kind: "reconcile-first" }
|
|
68
|
+
: error.status === 401
|
|
69
|
+
? { kind: "after-reconnect" }
|
|
70
|
+
: error.status === 429 && error.retryAfterMs !== undefined
|
|
71
|
+
? { kind: "after-delay", delayMs: error.retryAfterMs }
|
|
72
|
+
: !mutation && error.status !== undefined && error.status >= 500
|
|
73
|
+
? { kind: "after-delay", delayMs: 1000 }
|
|
74
|
+
: { 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 }),
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
return new SocialError({
|
|
80
|
+
code: "upstream_failure",
|
|
81
|
+
operation,
|
|
82
|
+
message: "Bluesky request failed.",
|
|
83
|
+
retryDisposition: { kind: "reconcile-first" },
|
|
84
|
+
cause: error,
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
// oxlint-disable-next-line anti-slop/no-unknown-parameters -- validated boundary or fixture contract.
|
|
88
|
+
function postRef(value) {
|
|
89
|
+
const record = object(value);
|
|
90
|
+
return { uri: string(record["uri"]), cid: string(record["cid"]) };
|
|
91
|
+
}
|
|
92
|
+
function linkFacets(text) {
|
|
93
|
+
const facets = [];
|
|
94
|
+
const urlPattern = /https?:\/\/[^\s<>]+/g;
|
|
95
|
+
for (const match of text.matchAll(urlPattern)) {
|
|
96
|
+
const value = match[0]?.replace(/[.,;:!?)]*$/u, "");
|
|
97
|
+
const start = match.index;
|
|
98
|
+
if (start === undefined || value === undefined)
|
|
99
|
+
continue;
|
|
100
|
+
const end = start + value.length;
|
|
101
|
+
const byteStart = new TextEncoder().encode(text.slice(0, start)).byteLength;
|
|
102
|
+
const byteEnd = new TextEncoder().encode(text.slice(0, end)).byteLength;
|
|
103
|
+
facets.push({
|
|
104
|
+
index: { byteStart, byteEnd },
|
|
105
|
+
features: [{ $type: "app.bsky.richtext.facet#link", uri: value }],
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
return facets;
|
|
109
|
+
}
|
|
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 ?? {});
|
|
121
|
+
if (Object.keys(config).some((key) => key !== "languages" && key !== "mentions"))
|
|
122
|
+
invalid("A supplied Bluesky option is not supported.");
|
|
123
|
+
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.");
|
|
131
|
+
try {
|
|
132
|
+
// oxlint-disable-next-line anti-slop/require-safety-comment-for-type-assertion -- validated boundary or fixture contract.
|
|
133
|
+
langs = Intl.getCanonicalLocales(languages);
|
|
134
|
+
}
|
|
135
|
+
catch {
|
|
136
|
+
invalid("Bluesky languages must be valid BCP 47 tags.");
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
const facets = [...linkFacets(text)];
|
|
140
|
+
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.");
|
|
144
|
+
const spans = [];
|
|
145
|
+
// oxlint-disable-next-line anti-slop/require-safety-comment-for-type-assertion -- validated boundary or fixture contract.
|
|
146
|
+
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;
|
|
171
|
+
if (!new TextDecoder().decode(bytes.slice(byteStart, byteEnd)).startsWith("@"))
|
|
172
|
+
invalid("A mention span must start with @.");
|
|
173
|
+
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.");
|
|
180
|
+
spans.push({ start: byteStart, end: byteEnd });
|
|
181
|
+
facets.push({
|
|
182
|
+
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 }],
|
|
185
|
+
});
|
|
186
|
+
}
|
|
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 } : {}) };
|
|
194
|
+
}
|
|
195
|
+
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
|
|
213
|
+
? Array.from(text).length
|
|
214
|
+
: [...new Segmenter(undefined, { granularity: "grapheme" }).segment(text)].length;
|
|
215
|
+
}
|
|
216
|
+
async function readMedia(source, fetcher, context, allowMediaHost) {
|
|
217
|
+
const duration = remainingBudget(context);
|
|
218
|
+
const controller = new AbortController();
|
|
219
|
+
const abort = () => controller.abort(context.signal?.reason);
|
|
220
|
+
if (context.signal?.aborted)
|
|
221
|
+
abort();
|
|
222
|
+
context.signal?.addEventListener("abort", abort, { once: true });
|
|
223
|
+
const timer = setTimeout(() => controller.abort(new Error("Media deadline exceeded")), duration);
|
|
224
|
+
try {
|
|
225
|
+
controller.signal.throwIfAborted();
|
|
226
|
+
if (source.kind === "blob") {
|
|
227
|
+
if (source.blob.size > MAX_IMAGE_BYTES)
|
|
228
|
+
throw new SocialError({
|
|
229
|
+
code: "media_error",
|
|
230
|
+
operation: "bluesky.uploadBlob",
|
|
231
|
+
message: "Image exceeds the 10 MiB limit.",
|
|
232
|
+
});
|
|
233
|
+
return {
|
|
234
|
+
bytes: await readBinary(source.blob.stream(), MAX_IMAGE_BYTES, controller.signal),
|
|
235
|
+
mimeType: source.blob.type || "application/octet-stream",
|
|
236
|
+
};
|
|
237
|
+
}
|
|
238
|
+
if (source.kind === "stream")
|
|
239
|
+
return {
|
|
240
|
+
bytes: await readBinary(source.open(), MAX_IMAGE_BYTES, controller.signal),
|
|
241
|
+
mimeType: "application/octet-stream",
|
|
242
|
+
};
|
|
243
|
+
if (source.kind === "https-url") {
|
|
244
|
+
const url = httpsUrl(source.url);
|
|
245
|
+
if (!allowMediaHost(url.hostname))
|
|
246
|
+
throw new SocialError({
|
|
247
|
+
code: "media_error",
|
|
248
|
+
operation: "bluesky.uploadBlob",
|
|
249
|
+
message: "Remote image host is outside the configured egress policy.",
|
|
250
|
+
});
|
|
251
|
+
const pending = fetcher(url, { redirect: "error", signal: controller.signal });
|
|
252
|
+
void pending.then((response) => {
|
|
253
|
+
if (controller.signal.aborted)
|
|
254
|
+
void response.body?.cancel().catch(() => undefined);
|
|
255
|
+
}, () => undefined);
|
|
256
|
+
const response = await abortable(pending, controller.signal);
|
|
257
|
+
if (!response.ok ||
|
|
258
|
+
!response.body ||
|
|
259
|
+
Number(response.headers.get("content-length")) > MAX_IMAGE_BYTES) {
|
|
260
|
+
void response.body?.cancel().catch(() => undefined);
|
|
261
|
+
throw new SocialError({
|
|
262
|
+
code: "media_error",
|
|
263
|
+
operation: "bluesky.uploadBlob",
|
|
264
|
+
message: "Remote image was unavailable or exceeded its byte limit.",
|
|
265
|
+
});
|
|
266
|
+
}
|
|
267
|
+
return {
|
|
268
|
+
bytes: await readBinary(response.body, MAX_IMAGE_BYTES, controller.signal),
|
|
269
|
+
mimeType: response.headers.get("content-type")?.split(";")[0] || "application/octet-stream",
|
|
270
|
+
};
|
|
271
|
+
}
|
|
272
|
+
throw new SocialError({
|
|
273
|
+
code: "media_error",
|
|
274
|
+
operation: "bluesky.uploadBlob",
|
|
275
|
+
message: "This image source cannot be uploaded.",
|
|
276
|
+
});
|
|
277
|
+
}
|
|
278
|
+
catch (error) {
|
|
279
|
+
if (controller.signal.aborted)
|
|
280
|
+
throw new SocialError({
|
|
281
|
+
code: context.signal?.aborted ? "cancelled" : "timeout",
|
|
282
|
+
operation: "bluesky.uploadBlob",
|
|
283
|
+
message: "Image preparation was interrupted before publication.",
|
|
284
|
+
retryDisposition: { kind: "never" },
|
|
285
|
+
});
|
|
286
|
+
throw error;
|
|
287
|
+
}
|
|
288
|
+
finally {
|
|
289
|
+
clearTimeout(timer);
|
|
290
|
+
context.signal?.removeEventListener("abort", abort);
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
export function bluesky(options) {
|
|
294
|
+
const backend = options.backend ?? "default";
|
|
295
|
+
const fetcher = options.fetch ?? globalThis.fetch;
|
|
296
|
+
const allowMediaHost = options.allowMediaHost ?? (() => false);
|
|
297
|
+
const http = createHttp({
|
|
298
|
+
fetch: options.session
|
|
299
|
+
? (input, init) => {
|
|
300
|
+
const url = new URL(input instanceof Request ? input.url : String(input));
|
|
301
|
+
// OAuthSession resolves a relative XRPC path against its verified token
|
|
302
|
+
// audience. Never override that audience with caller-provided service URLs.
|
|
303
|
+
return options.session.fetchHandler(url.pathname + url.search, init);
|
|
304
|
+
}
|
|
305
|
+
: 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 }),
|
|
308
|
+
});
|
|
309
|
+
const auth = options.auth;
|
|
310
|
+
if (options.session) {
|
|
311
|
+
if (options.session.did !== auth.did)
|
|
312
|
+
throw new SocialError({
|
|
313
|
+
code: "invalid_config",
|
|
314
|
+
operation: "bluesky.configure",
|
|
315
|
+
message: "OAuth session DID must match the configured account DID.",
|
|
316
|
+
});
|
|
317
|
+
if (auth.accessJwt !== undefined)
|
|
318
|
+
throw new SocialError({
|
|
319
|
+
code: "invalid_config",
|
|
320
|
+
operation: "bluesky.configure",
|
|
321
|
+
message: "Use OAuth session transport or bearer credentials, not both.",
|
|
322
|
+
});
|
|
323
|
+
}
|
|
324
|
+
else if (auth.accessJwt === undefined) {
|
|
325
|
+
throw new SocialError({
|
|
326
|
+
code: "invalid_config",
|
|
327
|
+
operation: "bluesky.configure",
|
|
328
|
+
message: "Bluesky authorization requires accessJwt or an OAuth session.",
|
|
329
|
+
});
|
|
330
|
+
}
|
|
331
|
+
const service = auth.service;
|
|
332
|
+
const account = connectedAccountRef({ backend, platform: "bluesky", accountId: auth.did });
|
|
333
|
+
const capabilities = {
|
|
334
|
+
schemaVersion: 1,
|
|
335
|
+
backend,
|
|
336
|
+
apiRevision: "AT Protocol XRPC 2026-09",
|
|
337
|
+
runtime: ["node>=22.12", "bun"],
|
|
338
|
+
capabilities: [
|
|
339
|
+
{
|
|
340
|
+
operation: "accounts.read",
|
|
341
|
+
platform: "bluesky",
|
|
342
|
+
availability: "available",
|
|
343
|
+
requiredScopes: ["repo"],
|
|
344
|
+
},
|
|
345
|
+
{
|
|
346
|
+
operation: "posts.publish",
|
|
347
|
+
platform: "bluesky",
|
|
348
|
+
availability: "available",
|
|
349
|
+
formats: ["text", "image"],
|
|
350
|
+
requiredScopes: ["repo"],
|
|
351
|
+
},
|
|
352
|
+
{
|
|
353
|
+
operation: "posts.read",
|
|
354
|
+
platform: "bluesky",
|
|
355
|
+
availability: "available",
|
|
356
|
+
formats: ["text", "image"],
|
|
357
|
+
},
|
|
358
|
+
...[
|
|
359
|
+
"graph.read",
|
|
360
|
+
"graph.follow",
|
|
361
|
+
"graph.unfollow",
|
|
362
|
+
"graph.block",
|
|
363
|
+
"graph.unblock",
|
|
364
|
+
"graph.mute",
|
|
365
|
+
"graph.unmute",
|
|
366
|
+
"likes.read",
|
|
367
|
+
"likes.write",
|
|
368
|
+
"lists.read",
|
|
369
|
+
"lists.write",
|
|
370
|
+
"profiles.search",
|
|
371
|
+
"moderation.report",
|
|
372
|
+
].map((operation) => ({
|
|
373
|
+
operation,
|
|
374
|
+
platform: "bluesky",
|
|
375
|
+
availability: "available",
|
|
376
|
+
requiredScopes: ["repo"],
|
|
377
|
+
})),
|
|
378
|
+
{
|
|
379
|
+
operation: "posts.list",
|
|
380
|
+
platform: "bluesky",
|
|
381
|
+
availability: "available",
|
|
382
|
+
formats: ["text", "image"],
|
|
383
|
+
},
|
|
384
|
+
{
|
|
385
|
+
operation: "comments.read",
|
|
386
|
+
platform: "bluesky",
|
|
387
|
+
availability: "available",
|
|
388
|
+
formats: ["text"],
|
|
389
|
+
},
|
|
390
|
+
{
|
|
391
|
+
operation: "analytics.read",
|
|
392
|
+
platform: "bluesky",
|
|
393
|
+
availability: "available",
|
|
394
|
+
formats: ["text", "image"],
|
|
395
|
+
},
|
|
396
|
+
{ operation: "analytics.account.read", platform: "bluesky", availability: "available" },
|
|
397
|
+
{
|
|
398
|
+
operation: "comments.write",
|
|
399
|
+
platform: "bluesky",
|
|
400
|
+
availability: "available",
|
|
401
|
+
formats: ["text"],
|
|
402
|
+
requiredScopes: ["repo"],
|
|
403
|
+
},
|
|
404
|
+
{
|
|
405
|
+
operation: "posts.publish.video",
|
|
406
|
+
platform: "bluesky",
|
|
407
|
+
availability: "not-implemented-by-adapter",
|
|
408
|
+
formats: ["video"],
|
|
409
|
+
requiredScopes: ["repo"],
|
|
410
|
+
},
|
|
411
|
+
...[
|
|
412
|
+
"posts.repost",
|
|
413
|
+
"posts.quote",
|
|
414
|
+
"posts.delete",
|
|
415
|
+
"posts.removeFromPlatform",
|
|
416
|
+
"notifications.read",
|
|
417
|
+
"notifications.seen",
|
|
418
|
+
"profile.read",
|
|
419
|
+
"profiles.read",
|
|
420
|
+
"profile.update",
|
|
421
|
+
"feeds.read",
|
|
422
|
+
"search.posts",
|
|
423
|
+
"chat.read",
|
|
424
|
+
"chat.write",
|
|
425
|
+
].map((operation) => ({
|
|
426
|
+
operation,
|
|
427
|
+
platform: "bluesky",
|
|
428
|
+
availability: operation.startsWith("chat.")
|
|
429
|
+
? "available"
|
|
430
|
+
: "available",
|
|
431
|
+
})),
|
|
432
|
+
],
|
|
433
|
+
};
|
|
434
|
+
async function xrpc(method, context, init = {}) {
|
|
435
|
+
try {
|
|
436
|
+
const headers = new Headers(init.headers);
|
|
437
|
+
if (typeof init.body === "string" && !headers.has("Content-Type"))
|
|
438
|
+
headers.set("Content-Type", "application/json");
|
|
439
|
+
if (method.startsWith("chat.bsky.") && !headers.has("atproto-proxy"))
|
|
440
|
+
headers.set("atproto-proxy", "did:web:api.bsky.chat#bsky_chat");
|
|
441
|
+
return await http({
|
|
442
|
+
url: endpoint(service, method),
|
|
443
|
+
timeoutMs: remainingBudget(context),
|
|
444
|
+
method: init.body === undefined ? "GET" : "POST",
|
|
445
|
+
...(options.session
|
|
446
|
+
? { headers }
|
|
447
|
+
: { 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 }),
|
|
452
|
+
maxAttempts: init.maxAttempts ??
|
|
453
|
+
(init.body === undefined ? Math.min(context.retryBudget.maxAttempts, 3) : 1),
|
|
454
|
+
});
|
|
455
|
+
}
|
|
456
|
+
catch (error) {
|
|
457
|
+
throw operationError(`bluesky.${method}`, error, init.body !== undefined);
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
async function verifiedSession(context) {
|
|
461
|
+
const session = object(await xrpc(options.session
|
|
462
|
+
? `app.bsky.actor.getProfile?actor=${encodeURIComponent(auth.did)}`
|
|
463
|
+
: "com.atproto.server.getSession", context));
|
|
464
|
+
const did = string(session["did"]);
|
|
465
|
+
if (did !== auth.did) {
|
|
466
|
+
throw new SocialError({
|
|
467
|
+
code: "unauthorized",
|
|
468
|
+
operation: "bluesky.accounts.get",
|
|
469
|
+
message: "The configured credential belongs to a different DID.",
|
|
470
|
+
});
|
|
471
|
+
}
|
|
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 }) };
|
|
476
|
+
}
|
|
477
|
+
const nativeContext = (context, operation) => context ?? {
|
|
478
|
+
correlationId: operation,
|
|
479
|
+
retryBudget: { maxAttempts: 2, maxElapsedMs: 30_000 },
|
|
480
|
+
backendInstance: backend,
|
|
481
|
+
};
|
|
482
|
+
const assertNativeAccount = (selected, context, operation) => {
|
|
483
|
+
if (!accountMatches(selected, auth, backend) || context.backendInstance !== backend)
|
|
484
|
+
throw new SocialError({
|
|
485
|
+
code: "unauthorized",
|
|
486
|
+
operation,
|
|
487
|
+
message: "Account reference does not belong to this Bluesky adapter.",
|
|
488
|
+
});
|
|
489
|
+
};
|
|
490
|
+
const pageQuery = (input) => {
|
|
491
|
+
const limit = input.limit ?? 50;
|
|
492
|
+
if (!Number.isSafeInteger(limit) || limit < 1 || limit > 100)
|
|
493
|
+
throw new SocialError({
|
|
494
|
+
code: "invalid_input",
|
|
495
|
+
operation: "bluesky.graph.read",
|
|
496
|
+
message: "Page size must be between 1 and 100.",
|
|
497
|
+
});
|
|
498
|
+
return new URLSearchParams({
|
|
499
|
+
limit: String(limit),
|
|
500
|
+
...(input.cursor ? { cursor: input.cursor } : {}),
|
|
501
|
+
});
|
|
502
|
+
};
|
|
503
|
+
const recordKey = (uri, collection) => {
|
|
504
|
+
const prefix = `at://${auth.did}/${collection}/`;
|
|
505
|
+
const key = uri.startsWith(prefix) ? uri.slice(prefix.length) : "";
|
|
506
|
+
if (!/^[A-Za-z0-9._~:-]{1,512}$/.test(key) || key === "." || key === "..")
|
|
507
|
+
throw new SocialError({
|
|
508
|
+
code: "invalid_input",
|
|
509
|
+
operation: "bluesky.record.delete",
|
|
510
|
+
message: "Record URI must belong to this account.",
|
|
511
|
+
});
|
|
512
|
+
return key;
|
|
513
|
+
};
|
|
514
|
+
const actorPage = (response, key) => ({
|
|
515
|
+
actors: array(response[key] ?? []).map((value) => object(value)),
|
|
516
|
+
...(typeof response["cursor"] === "string" ? { cursor: response["cursor"] } : {}),
|
|
517
|
+
});
|
|
518
|
+
const native = {
|
|
519
|
+
async getPost(input) {
|
|
520
|
+
const context = input.context ?? {
|
|
521
|
+
correlationId: "bluesky-native",
|
|
522
|
+
retryBudget: { maxAttempts: 2, maxElapsedMs: 30_000 },
|
|
523
|
+
backendInstance: backend,
|
|
524
|
+
};
|
|
525
|
+
const query = new URLSearchParams({ uris: input.uri });
|
|
526
|
+
const response = object(await xrpc(`app.bsky.feed.getPosts?${query.toString()}`, context));
|
|
527
|
+
const posts = array(response["posts"]);
|
|
528
|
+
const post = posts.find((value) => object(value)["uri"] === input.uri);
|
|
529
|
+
if (post === undefined)
|
|
530
|
+
throw new SocialError({
|
|
531
|
+
code: "upstream_failure",
|
|
532
|
+
operation: "bluesky.getPost",
|
|
533
|
+
message: "Bluesky post was not found.",
|
|
534
|
+
});
|
|
535
|
+
// oxlint-disable-next-line anti-slop/require-safety-comment-for-type-assertion -- validated boundary or fixture contract.
|
|
536
|
+
return object(post);
|
|
537
|
+
},
|
|
538
|
+
async getPostThread(input) {
|
|
539
|
+
const context = input.context ?? {
|
|
540
|
+
correlationId: "bluesky-native",
|
|
541
|
+
retryBudget: { maxAttempts: 2, maxElapsedMs: 30_000 },
|
|
542
|
+
backendInstance: backend,
|
|
543
|
+
};
|
|
544
|
+
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
|
+
return object(await xrpc(`app.bsky.feed.getPostThread?${query.toString()}`, context));
|
|
547
|
+
},
|
|
548
|
+
async searchPosts(input) {
|
|
549
|
+
const context = nativeContext(input.context, "bluesky.search.posts");
|
|
550
|
+
assertNativeAccount(input.account, context, "bluesky.search.posts");
|
|
551
|
+
const queryText = input.query.trim();
|
|
552
|
+
if (!queryText)
|
|
553
|
+
throw new SocialError({
|
|
554
|
+
code: "invalid_input",
|
|
555
|
+
operation: "bluesky.search.posts",
|
|
556
|
+
message: "Bluesky search query is required.",
|
|
557
|
+
retryDisposition: { kind: "never" },
|
|
558
|
+
});
|
|
559
|
+
const limit = input.limit ?? 50;
|
|
560
|
+
if (!Number.isSafeInteger(limit) || limit < 1 || limit > 100)
|
|
561
|
+
throw new SocialError({
|
|
562
|
+
code: "invalid_input",
|
|
563
|
+
operation: "bluesky.search.posts",
|
|
564
|
+
message: "Bluesky search page size must be between 1 and 100.",
|
|
565
|
+
retryDisposition: { kind: "never" },
|
|
566
|
+
});
|
|
567
|
+
if (input.sort !== undefined && input.sort !== "latest" && input.sort !== "top")
|
|
568
|
+
throw new SocialError({
|
|
569
|
+
code: "invalid_input",
|
|
570
|
+
operation: "bluesky.search.posts",
|
|
571
|
+
message: "Bluesky search sort must be latest or top.",
|
|
572
|
+
retryDisposition: { kind: "never" },
|
|
573
|
+
});
|
|
574
|
+
if (input.scope === "all")
|
|
575
|
+
throw new SocialError({
|
|
576
|
+
code: "invalid_input",
|
|
577
|
+
operation: "bluesky.search.posts",
|
|
578
|
+
message: "Bluesky search does not support scope 'all'.",
|
|
579
|
+
});
|
|
580
|
+
if (input.context !== undefined && input.context.backendInstance !== backend)
|
|
581
|
+
throw new SocialError({
|
|
582
|
+
code: "unauthorized",
|
|
583
|
+
operation: "bluesky.search.posts",
|
|
584
|
+
message: "Account reference does not belong to this Bluesky adapter.",
|
|
585
|
+
});
|
|
586
|
+
const query = new URLSearchParams({ q: queryText, limit: String(limit) });
|
|
587
|
+
const optional = {
|
|
588
|
+
cursor: input.cursor,
|
|
589
|
+
sort: input.sort,
|
|
590
|
+
since: input.since,
|
|
591
|
+
until: input.until,
|
|
592
|
+
mentions: input.mentions,
|
|
593
|
+
author: input.author,
|
|
594
|
+
lang: input.lang,
|
|
595
|
+
domain: input.domain,
|
|
596
|
+
url: input.url,
|
|
597
|
+
};
|
|
598
|
+
for (const [key, value] of Object.entries(optional))
|
|
599
|
+
if (value !== undefined && value.trim() !== "")
|
|
600
|
+
query.set(key, value);
|
|
601
|
+
for (const tag of input.tags ?? []) {
|
|
602
|
+
const normalizedTag = tag.trim();
|
|
603
|
+
if (normalizedTag)
|
|
604
|
+
query.append("tag", normalizedTag);
|
|
605
|
+
}
|
|
606
|
+
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"];
|
|
616
|
+
return {
|
|
617
|
+
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
|
+
: {}),
|
|
624
|
+
};
|
|
625
|
+
},
|
|
626
|
+
async likePost(input) {
|
|
627
|
+
if (!accountMatches(input.account, auth, backend) ||
|
|
628
|
+
(input.context && input.context.backendInstance !== backend))
|
|
629
|
+
throw new SocialError({
|
|
630
|
+
code: "unauthorized",
|
|
631
|
+
operation: "bluesky.reactions.like",
|
|
632
|
+
message: "Account reference does not belong to this Bluesky adapter.",
|
|
633
|
+
});
|
|
634
|
+
if (!/^at:\/\/did:[a-z]+:[A-Za-z0-9._:%-]+\/app\.bsky\.feed\.post\/[A-Za-z0-9._~:-]{1,512}$/.test(input.post.uri) ||
|
|
635
|
+
!/^[A-Za-z0-9]{1,2048}$/.test(input.post.cid))
|
|
636
|
+
throw new SocialError({
|
|
637
|
+
code: "invalid_input",
|
|
638
|
+
operation: "bluesky.reactions.like",
|
|
639
|
+
message: "Provide the native post URI and CID returned by a Bluesky read.",
|
|
640
|
+
});
|
|
641
|
+
const context = input.context ?? {
|
|
642
|
+
correlationId: "bluesky-like",
|
|
643
|
+
retryBudget: { maxAttempts: 1, maxElapsedMs: 30_000 },
|
|
644
|
+
backendInstance: backend,
|
|
645
|
+
};
|
|
646
|
+
const response = object(await xrpc("com.atproto.repo.createRecord", context, {
|
|
647
|
+
body: JSON.stringify({
|
|
648
|
+
repo: auth.did,
|
|
649
|
+
collection: "app.bsky.feed.like",
|
|
650
|
+
record: {
|
|
651
|
+
$type: "app.bsky.feed.like",
|
|
652
|
+
subject: { uri: input.post.uri, cid: input.post.cid },
|
|
653
|
+
createdAt: new Date().toISOString(),
|
|
654
|
+
},
|
|
655
|
+
}),
|
|
656
|
+
}));
|
|
657
|
+
const prefix = `at://${auth.did}/app.bsky.feed.like/`;
|
|
658
|
+
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" ||
|
|
662
|
+
!uri.startsWith(prefix) ||
|
|
663
|
+
!/^[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" ||
|
|
666
|
+
!cid)
|
|
667
|
+
throw new SocialError({
|
|
668
|
+
code: "ambiguous_outcome",
|
|
669
|
+
operation: "bluesky.reactions.like",
|
|
670
|
+
message: "Bluesky did not return an owned like record. Reconcile before retrying.",
|
|
671
|
+
retryDisposition: { kind: "reconcile-first" },
|
|
672
|
+
});
|
|
673
|
+
return { uri, cid };
|
|
674
|
+
},
|
|
675
|
+
async unlikePost(input) {
|
|
676
|
+
if (!accountMatches(input.account, auth, backend) ||
|
|
677
|
+
(input.context && input.context.backendInstance !== backend))
|
|
678
|
+
throw new SocialError({
|
|
679
|
+
code: "unauthorized",
|
|
680
|
+
operation: "bluesky.reactions.unlike",
|
|
681
|
+
message: "Account reference does not belong to this Bluesky adapter.",
|
|
682
|
+
});
|
|
683
|
+
const context = input.context ?? {
|
|
684
|
+
correlationId: "bluesky-unlike",
|
|
685
|
+
retryBudget: { maxAttempts: 1, maxElapsedMs: 30_000 },
|
|
686
|
+
backendInstance: backend,
|
|
687
|
+
};
|
|
688
|
+
const prefix = `at://${auth.did}/app.bsky.feed.like/`;
|
|
689
|
+
const recordKey = input.likeUri.startsWith(prefix) ? input.likeUri.slice(prefix.length) : "";
|
|
690
|
+
if (!/^[A-Za-z0-9._~:-]{1,512}$/.test(recordKey) || recordKey === "." || recordKey === "..")
|
|
691
|
+
throw new SocialError({
|
|
692
|
+
code: "unauthorized",
|
|
693
|
+
operation: "bluesky.reactions.unlike",
|
|
694
|
+
message: "Like record must belong to this Bluesky account.",
|
|
695
|
+
});
|
|
696
|
+
await xrpc("com.atproto.repo.deleteRecord", context, {
|
|
697
|
+
body: JSON.stringify({ repo: auth.did, collection: "app.bsky.feed.like", rkey: recordKey }),
|
|
698
|
+
});
|
|
699
|
+
},
|
|
700
|
+
async repostPost(input) {
|
|
701
|
+
const context = nativeContext(input.context, "bluesky.repost");
|
|
702
|
+
assertNativeAccount(input.account, context, "bluesky.repost");
|
|
703
|
+
const result = object(await xrpc("com.atproto.repo.createRecord", context, {
|
|
704
|
+
body: JSON.stringify({
|
|
705
|
+
repo: auth.did,
|
|
706
|
+
collection: "app.bsky.feed.repost",
|
|
707
|
+
record: {
|
|
708
|
+
$type: "app.bsky.feed.repost",
|
|
709
|
+
subject: input.post,
|
|
710
|
+
createdAt: new Date().toISOString(),
|
|
711
|
+
},
|
|
712
|
+
}),
|
|
713
|
+
}));
|
|
714
|
+
return postRef(result);
|
|
715
|
+
},
|
|
716
|
+
async quotePost(input) {
|
|
717
|
+
const context = nativeContext(input.context, "bluesky.quote");
|
|
718
|
+
assertNativeAccount(input.account, context, "bluesky.quote");
|
|
719
|
+
if (!input.text.trim() || graphemeCount(input.text) > 300)
|
|
720
|
+
throw new SocialError({
|
|
721
|
+
code: "invalid_input",
|
|
722
|
+
operation: "bluesky.quote",
|
|
723
|
+
message: "Quote text must be 1-300 characters.",
|
|
724
|
+
});
|
|
725
|
+
const result = object(await xrpc("com.atproto.repo.createRecord", context, {
|
|
726
|
+
body: JSON.stringify({
|
|
727
|
+
repo: auth.did,
|
|
728
|
+
collection: "app.bsky.feed.post",
|
|
729
|
+
record: {
|
|
730
|
+
$type: "app.bsky.feed.post",
|
|
731
|
+
text: input.text,
|
|
732
|
+
createdAt: new Date().toISOString(),
|
|
733
|
+
embed: { $type: "app.bsky.embed.record", record: input.post },
|
|
734
|
+
},
|
|
735
|
+
}),
|
|
736
|
+
}));
|
|
737
|
+
return postRef(result);
|
|
738
|
+
},
|
|
739
|
+
async deletePost(input) {
|
|
740
|
+
const context = nativeContext(input.context, "bluesky.delete");
|
|
741
|
+
assertNativeAccount(input.account, context, "bluesky.delete");
|
|
742
|
+
const prefix = `at://${auth.did}/app.bsky.feed.post/`;
|
|
743
|
+
const rkey = input.post.uri.startsWith(prefix) ? input.post.uri.slice(prefix.length) : "";
|
|
744
|
+
if (!rkey)
|
|
745
|
+
throw new SocialError({
|
|
746
|
+
code: "unauthorized",
|
|
747
|
+
operation: "bluesky.delete",
|
|
748
|
+
message: "Post URI does not belong to this account.",
|
|
749
|
+
});
|
|
750
|
+
await xrpc("com.atproto.repo.deleteRecord", context, {
|
|
751
|
+
body: JSON.stringify({ repo: auth.did, collection: "app.bsky.feed.post", rkey }),
|
|
752
|
+
});
|
|
753
|
+
},
|
|
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
|
+
});
|
|
760
|
+
},
|
|
761
|
+
async follow(input) {
|
|
762
|
+
const context = nativeContext(input.context, "bluesky.follow");
|
|
763
|
+
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
|
+
return object(await xrpc("com.atproto.repo.createRecord", context, {
|
|
766
|
+
body: JSON.stringify({
|
|
767
|
+
repo: auth.did,
|
|
768
|
+
collection: "app.bsky.graph.follow",
|
|
769
|
+
record: {
|
|
770
|
+
$type: "app.bsky.graph.follow",
|
|
771
|
+
subject: input.did,
|
|
772
|
+
createdAt: new Date().toISOString(),
|
|
773
|
+
},
|
|
774
|
+
}),
|
|
775
|
+
}));
|
|
776
|
+
},
|
|
777
|
+
async unfollow(input) {
|
|
778
|
+
const context = nativeContext(input.context, "bluesky.unfollow");
|
|
779
|
+
assertNativeAccount(input.account, context, "bluesky.unfollow");
|
|
780
|
+
await xrpc("com.atproto.repo.deleteRecord", context, {
|
|
781
|
+
body: JSON.stringify({
|
|
782
|
+
repo: auth.did,
|
|
783
|
+
collection: "app.bsky.graph.follow",
|
|
784
|
+
rkey: recordKey(input.followUri, "app.bsky.graph.follow"),
|
|
785
|
+
}),
|
|
786
|
+
});
|
|
787
|
+
},
|
|
788
|
+
async block(input) {
|
|
789
|
+
const context = nativeContext(input.context, "bluesky.block");
|
|
790
|
+
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
|
+
return object(await xrpc("com.atproto.repo.createRecord", context, {
|
|
793
|
+
body: JSON.stringify({
|
|
794
|
+
repo: auth.did,
|
|
795
|
+
collection: "app.bsky.graph.block",
|
|
796
|
+
record: {
|
|
797
|
+
$type: "app.bsky.graph.block",
|
|
798
|
+
subject: input.did,
|
|
799
|
+
createdAt: new Date().toISOString(),
|
|
800
|
+
},
|
|
801
|
+
}),
|
|
802
|
+
}));
|
|
803
|
+
},
|
|
804
|
+
async unblock(input) {
|
|
805
|
+
const context = nativeContext(input.context, "bluesky.unblock");
|
|
806
|
+
assertNativeAccount(input.account, context, "bluesky.unblock");
|
|
807
|
+
await xrpc("com.atproto.repo.deleteRecord", context, {
|
|
808
|
+
body: JSON.stringify({
|
|
809
|
+
repo: auth.did,
|
|
810
|
+
collection: "app.bsky.graph.block",
|
|
811
|
+
rkey: recordKey(input.blockUri, "app.bsky.graph.block"),
|
|
812
|
+
}),
|
|
813
|
+
});
|
|
814
|
+
},
|
|
815
|
+
async mute(input) {
|
|
816
|
+
const context = nativeContext(input.context, "bluesky.mute");
|
|
817
|
+
assertNativeAccount(input.account, context, "bluesky.mute");
|
|
818
|
+
await xrpc("app.bsky.graph.muteActor", context, {
|
|
819
|
+
body: JSON.stringify({ actor: input.did }),
|
|
820
|
+
});
|
|
821
|
+
},
|
|
822
|
+
async unmute(input) {
|
|
823
|
+
const context = nativeContext(input.context, "bluesky.unmute");
|
|
824
|
+
assertNativeAccount(input.account, context, "bluesky.unmute");
|
|
825
|
+
await xrpc("app.bsky.graph.unmuteActor", context, {
|
|
826
|
+
body: JSON.stringify({ actor: input.did }),
|
|
827
|
+
});
|
|
828
|
+
},
|
|
829
|
+
async getFollowers(input) {
|
|
830
|
+
const context = nativeContext(input.context, "bluesky.graph.followers");
|
|
831
|
+
assertNativeAccount(input.account, context, "bluesky.graph.followers");
|
|
832
|
+
const q = pageQuery(input);
|
|
833
|
+
q.set("actor", input.actor ?? auth.did);
|
|
834
|
+
return actorPage(object(await xrpc(`app.bsky.graph.getFollowers?${q}`, context)), "followers");
|
|
835
|
+
},
|
|
836
|
+
async getFollows(input) {
|
|
837
|
+
const context = nativeContext(input.context, "bluesky.graph.follows");
|
|
838
|
+
assertNativeAccount(input.account, context, "bluesky.graph.follows");
|
|
839
|
+
const q = pageQuery(input);
|
|
840
|
+
q.set("actor", input.actor ?? auth.did);
|
|
841
|
+
return actorPage(object(await xrpc(`app.bsky.graph.getFollows?${q}`, context)), "follows");
|
|
842
|
+
},
|
|
843
|
+
async getMutes(input) {
|
|
844
|
+
const context = nativeContext(input.context, "bluesky.graph.mutes");
|
|
845
|
+
assertNativeAccount(input.account, context, "bluesky.graph.mutes");
|
|
846
|
+
return actorPage(object(await xrpc(`app.bsky.graph.getMutes?${pageQuery(input)}`, context)), "mutes");
|
|
847
|
+
},
|
|
848
|
+
async getBlocks(input) {
|
|
849
|
+
const context = nativeContext(input.context, "bluesky.graph.blocks");
|
|
850
|
+
assertNativeAccount(input.account, context, "bluesky.graph.blocks");
|
|
851
|
+
return actorPage(object(await xrpc(`app.bsky.graph.getBlocks?${pageQuery(input)}`, context)), "blocks");
|
|
852
|
+
},
|
|
853
|
+
async getLikes(input) {
|
|
854
|
+
const context = nativeContext(input.context, "bluesky.likes.read");
|
|
855
|
+
assertNativeAccount(input.account, context, "bluesky.likes.read");
|
|
856
|
+
const q = pageQuery(input);
|
|
857
|
+
q.set("uri", input.uri);
|
|
858
|
+
if (input.cid)
|
|
859
|
+
q.set("cid", input.cid);
|
|
860
|
+
const value = object(await xrpc(`app.bsky.feed.getLikes?${q}`, context));
|
|
861
|
+
return {
|
|
862
|
+
likes: array(value["likes"] ?? []).map((entry) => object(entry)),
|
|
863
|
+
...(typeof value["cursor"] === "string" ? { cursor: value["cursor"] } : {}),
|
|
864
|
+
};
|
|
865
|
+
},
|
|
866
|
+
async getActorLikes(input) {
|
|
867
|
+
const context = nativeContext(input.context, "bluesky.likes.actor");
|
|
868
|
+
assertNativeAccount(input.account, context, "bluesky.likes.actor");
|
|
869
|
+
const q = pageQuery(input);
|
|
870
|
+
q.set("actor", input.actor ?? auth.did);
|
|
871
|
+
const value = object(await xrpc(`app.bsky.feed.getActorLikes?${q}`, context));
|
|
872
|
+
return {
|
|
873
|
+
feed: array(value["feed"] ?? []).map((entry) => object(entry)),
|
|
874
|
+
...(typeof value["cursor"] === "string" ? { cursor: value["cursor"] } : {}),
|
|
875
|
+
};
|
|
876
|
+
},
|
|
877
|
+
async searchActors(input) {
|
|
878
|
+
const context = nativeContext(input.context, "bluesky.profiles.search");
|
|
879
|
+
assertNativeAccount(input.account, context, "bluesky.profiles.search");
|
|
880
|
+
if (!input.query.trim())
|
|
881
|
+
throw new SocialError({
|
|
882
|
+
code: "invalid_input",
|
|
883
|
+
operation: "bluesky.profiles.search",
|
|
884
|
+
message: "Actor search query is required.",
|
|
885
|
+
});
|
|
886
|
+
const q = pageQuery(input);
|
|
887
|
+
q.set("q", input.query.trim());
|
|
888
|
+
return actorPage(object(await xrpc(`app.bsky.actor.searchActors?${q}`, context)), "actors");
|
|
889
|
+
},
|
|
890
|
+
async searchActorsTypeahead(input) {
|
|
891
|
+
const context = nativeContext(input.context, "bluesky.profiles.search");
|
|
892
|
+
assertNativeAccount(input.account, context, "bluesky.profiles.search");
|
|
893
|
+
if (!input.query.trim())
|
|
894
|
+
throw new SocialError({
|
|
895
|
+
code: "invalid_input",
|
|
896
|
+
operation: "bluesky.profiles.search",
|
|
897
|
+
message: "Actor search query is required.",
|
|
898
|
+
});
|
|
899
|
+
const limit = input.limit ?? 8;
|
|
900
|
+
if (!Number.isSafeInteger(limit) || limit < 1 || limit > 100)
|
|
901
|
+
throw new SocialError({
|
|
902
|
+
code: "invalid_input",
|
|
903
|
+
operation: "bluesky.profiles.search",
|
|
904
|
+
message: "Page size must be between 1 and 100.",
|
|
905
|
+
});
|
|
906
|
+
const q = new URLSearchParams({ q: input.query.trim(), limit: String(limit) });
|
|
907
|
+
return actorPage(object(await xrpc(`app.bsky.actor.searchActorsTypeahead?${q}`, context)), "actors");
|
|
908
|
+
},
|
|
909
|
+
async createList(input) {
|
|
910
|
+
const context = nativeContext(input.context, "bluesky.lists.create");
|
|
911
|
+
assertNativeAccount(input.account, context, "bluesky.lists.create");
|
|
912
|
+
const record = {
|
|
913
|
+
$type: "app.bsky.graph.list",
|
|
914
|
+
name: input.name,
|
|
915
|
+
purpose: input.purpose,
|
|
916
|
+
createdAt: new Date().toISOString(),
|
|
917
|
+
...(input.description === undefined ? {} : { description: input.description }),
|
|
918
|
+
};
|
|
919
|
+
return postRef(object(await xrpc("com.atproto.repo.createRecord", context, {
|
|
920
|
+
body: JSON.stringify({ repo: auth.did, collection: "app.bsky.graph.list", record }),
|
|
921
|
+
})));
|
|
922
|
+
},
|
|
923
|
+
async updateList(input) {
|
|
924
|
+
const context = nativeContext(input.context, "bluesky.lists.update");
|
|
925
|
+
assertNativeAccount(input.account, context, "bluesky.lists.update");
|
|
926
|
+
const existing = object(await xrpc(`com.atproto.repo.getRecord?repo=${encodeURIComponent(auth.did)}&collection=app.bsky.graph.list&rkey=${encodeURIComponent(recordKey(input.listUri, "app.bsky.graph.list"))}`, context));
|
|
927
|
+
const record = object(existing["value"]);
|
|
928
|
+
const next = {
|
|
929
|
+
...record,
|
|
930
|
+
name: input.name,
|
|
931
|
+
purpose: input.purpose,
|
|
932
|
+
...(input.description === undefined ? {} : { description: input.description }),
|
|
933
|
+
};
|
|
934
|
+
await xrpc("com.atproto.repo.putRecord", context, {
|
|
935
|
+
body: JSON.stringify({
|
|
936
|
+
repo: auth.did,
|
|
937
|
+
collection: "app.bsky.graph.list",
|
|
938
|
+
rkey: recordKey(input.listUri, "app.bsky.graph.list"),
|
|
939
|
+
record: next,
|
|
940
|
+
}),
|
|
941
|
+
});
|
|
942
|
+
},
|
|
943
|
+
async deleteList(input) {
|
|
944
|
+
const context = nativeContext(input.context, "bluesky.lists.delete");
|
|
945
|
+
assertNativeAccount(input.account, context, "bluesky.lists.delete");
|
|
946
|
+
await xrpc("com.atproto.repo.deleteRecord", context, {
|
|
947
|
+
body: JSON.stringify({
|
|
948
|
+
repo: auth.did,
|
|
949
|
+
collection: "app.bsky.graph.list",
|
|
950
|
+
rkey: recordKey(input.uri, "app.bsky.graph.list"),
|
|
951
|
+
}),
|
|
952
|
+
});
|
|
953
|
+
},
|
|
954
|
+
async addListItem(input) {
|
|
955
|
+
const context = nativeContext(input.context, "bluesky.lists.items.add");
|
|
956
|
+
assertNativeAccount(input.account, context, "bluesky.lists.items.add");
|
|
957
|
+
return postRef(object(await xrpc("com.atproto.repo.createRecord", context, {
|
|
958
|
+
body: JSON.stringify({
|
|
959
|
+
repo: auth.did,
|
|
960
|
+
collection: "app.bsky.graph.listitem",
|
|
961
|
+
record: {
|
|
962
|
+
$type: "app.bsky.graph.listitem",
|
|
963
|
+
list: input.listUri,
|
|
964
|
+
subject: input.subject,
|
|
965
|
+
createdAt: new Date().toISOString(),
|
|
966
|
+
},
|
|
967
|
+
}),
|
|
968
|
+
})));
|
|
969
|
+
},
|
|
970
|
+
async removeListItem(input) {
|
|
971
|
+
const context = nativeContext(input.context, "bluesky.lists.items.remove");
|
|
972
|
+
assertNativeAccount(input.account, context, "bluesky.lists.items.remove");
|
|
973
|
+
await xrpc("com.atproto.repo.deleteRecord", context, {
|
|
974
|
+
body: JSON.stringify({
|
|
975
|
+
repo: auth.did,
|
|
976
|
+
collection: "app.bsky.graph.listitem",
|
|
977
|
+
rkey: recordKey(input.uri, "app.bsky.graph.listitem"),
|
|
978
|
+
}),
|
|
979
|
+
});
|
|
980
|
+
},
|
|
981
|
+
async getList(input) {
|
|
982
|
+
const context = nativeContext(input.context, "bluesky.lists.get");
|
|
983
|
+
assertNativeAccount(input.account, context, "bluesky.lists.get");
|
|
984
|
+
const q = pageQuery(input);
|
|
985
|
+
q.set("list", input.listUri);
|
|
986
|
+
return object(await xrpc(`app.bsky.graph.getList?${q}`, context));
|
|
987
|
+
},
|
|
988
|
+
async getLists(input) {
|
|
989
|
+
const context = nativeContext(input.context, "bluesky.lists.list");
|
|
990
|
+
assertNativeAccount(input.account, context, "bluesky.lists.list");
|
|
991
|
+
const q = pageQuery(input);
|
|
992
|
+
q.set("actor", input.actor ?? auth.did);
|
|
993
|
+
return object(await xrpc(`app.bsky.graph.getLists?${q}`, context));
|
|
994
|
+
},
|
|
995
|
+
async muteList(input) {
|
|
996
|
+
const context = nativeContext(input.context, "bluesky.lists.mute");
|
|
997
|
+
assertNativeAccount(input.account, context, "bluesky.lists.mute");
|
|
998
|
+
await xrpc("app.bsky.graph.muteActorList", context, {
|
|
999
|
+
body: JSON.stringify({ list: input.listUri }),
|
|
1000
|
+
});
|
|
1001
|
+
},
|
|
1002
|
+
async unmuteList(input) {
|
|
1003
|
+
const context = nativeContext(input.context, "bluesky.lists.unmute");
|
|
1004
|
+
assertNativeAccount(input.account, context, "bluesky.lists.unmute");
|
|
1005
|
+
await xrpc("app.bsky.graph.unmuteActorList", context, {
|
|
1006
|
+
body: JSON.stringify({ list: input.listUri }),
|
|
1007
|
+
});
|
|
1008
|
+
},
|
|
1009
|
+
async blockList(input) {
|
|
1010
|
+
const context = nativeContext(input.context, "bluesky.lists.block");
|
|
1011
|
+
assertNativeAccount(input.account, context, "bluesky.lists.block");
|
|
1012
|
+
await xrpc("com.atproto.repo.createRecord", context, {
|
|
1013
|
+
body: JSON.stringify({
|
|
1014
|
+
repo: auth.did,
|
|
1015
|
+
collection: "app.bsky.graph.listblock",
|
|
1016
|
+
record: {
|
|
1017
|
+
$type: "app.bsky.graph.listblock",
|
|
1018
|
+
subject: input.listUri,
|
|
1019
|
+
createdAt: new Date().toISOString(),
|
|
1020
|
+
},
|
|
1021
|
+
}),
|
|
1022
|
+
});
|
|
1023
|
+
},
|
|
1024
|
+
async unblockList(input) {
|
|
1025
|
+
const context = nativeContext(input.context, "bluesky.lists.unblock");
|
|
1026
|
+
assertNativeAccount(input.account, context, "bluesky.lists.unblock");
|
|
1027
|
+
let cursor;
|
|
1028
|
+
let record;
|
|
1029
|
+
do {
|
|
1030
|
+
const query = new URLSearchParams({
|
|
1031
|
+
repo: auth.did,
|
|
1032
|
+
collection: "app.bsky.graph.listblock",
|
|
1033
|
+
limit: "100",
|
|
1034
|
+
...(cursor === undefined ? {} : { cursor }),
|
|
1035
|
+
});
|
|
1036
|
+
const records = object(await xrpc(`com.atproto.repo.listRecords?${query.toString()}`, context));
|
|
1037
|
+
record = array(records["records"])
|
|
1038
|
+
.map((value) => object(value))
|
|
1039
|
+
.find((value) => object(value["value"])["subject"] === input.listUri);
|
|
1040
|
+
cursor = typeof records["cursor"] === "string" ? records["cursor"] : undefined;
|
|
1041
|
+
} while (record === undefined && cursor !== undefined);
|
|
1042
|
+
if (record === undefined)
|
|
1043
|
+
throw new SocialError({
|
|
1044
|
+
code: "invalid_input",
|
|
1045
|
+
operation: "bluesky.lists.unblock",
|
|
1046
|
+
message: "No list block record was found for this list.",
|
|
1047
|
+
});
|
|
1048
|
+
await xrpc("com.atproto.repo.deleteRecord", context, {
|
|
1049
|
+
body: JSON.stringify({
|
|
1050
|
+
repo: auth.did,
|
|
1051
|
+
collection: "app.bsky.graph.listblock",
|
|
1052
|
+
rkey: recordKey(string(record["uri"]), "app.bsky.graph.listblock"),
|
|
1053
|
+
}),
|
|
1054
|
+
});
|
|
1055
|
+
},
|
|
1056
|
+
async createModerationReport(input) {
|
|
1057
|
+
const context = nativeContext(input.context, "bluesky.moderation.report");
|
|
1058
|
+
assertNativeAccount(input.account, context, "bluesky.moderation.report");
|
|
1059
|
+
const body = {
|
|
1060
|
+
reasonType: input.reasonType,
|
|
1061
|
+
subject: input.subject,
|
|
1062
|
+
...(input.reason === undefined ? {} : { reason: input.reason }),
|
|
1063
|
+
};
|
|
1064
|
+
return object(await xrpc("com.atproto.moderation.createReport", context, { body: JSON.stringify(body) }));
|
|
1065
|
+
},
|
|
1066
|
+
async listNotifications(input) {
|
|
1067
|
+
const context = nativeContext(input.context, "bluesky.notifications.list");
|
|
1068
|
+
assertNativeAccount(input.account, context, "bluesky.notifications.list");
|
|
1069
|
+
const limit = input.limit ?? 50;
|
|
1070
|
+
if (!Number.isSafeInteger(limit) || limit < 1 || limit > 100)
|
|
1071
|
+
throw new SocialError({
|
|
1072
|
+
code: "invalid_input",
|
|
1073
|
+
operation: "bluesky.notifications.list",
|
|
1074
|
+
message: "Page size must be between 1 and 100.",
|
|
1075
|
+
});
|
|
1076
|
+
const query = new URLSearchParams({
|
|
1077
|
+
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 } : {}),
|
|
1080
|
+
});
|
|
1081
|
+
// oxlint-disable-next-line anti-slop/require-safety-comment-for-type-assertion -- validated boundary or fixture contract.
|
|
1082
|
+
return object(await xrpc(`app.bsky.notification.listNotifications?${query}`, context));
|
|
1083
|
+
},
|
|
1084
|
+
async markNotificationsSeen(input) {
|
|
1085
|
+
const context = nativeContext(input.context, "bluesky.notifications.seen");
|
|
1086
|
+
assertNativeAccount(input.account, context, "bluesky.notifications.seen");
|
|
1087
|
+
await xrpc("app.bsky.notification.updateSeen", context, {
|
|
1088
|
+
body: JSON.stringify({ seenAt: input.seenAt ?? new Date().toISOString() }),
|
|
1089
|
+
});
|
|
1090
|
+
},
|
|
1091
|
+
async getProfile(input) {
|
|
1092
|
+
const context = nativeContext(input.context, "bluesky.profile.get");
|
|
1093
|
+
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
|
+
return object(await xrpc(`app.bsky.actor.getProfile?actor=${encodeURIComponent(input.actor ?? auth.did)}`, context));
|
|
1096
|
+
},
|
|
1097
|
+
async updateProfile(input) {
|
|
1098
|
+
const context = nativeContext(input.context, "bluesky.profile.update");
|
|
1099
|
+
assertNativeAccount(input.account, context, "bluesky.profile.update");
|
|
1100
|
+
await xrpc("com.atproto.repo.putRecord", context, {
|
|
1101
|
+
body: JSON.stringify({
|
|
1102
|
+
repo: auth.did,
|
|
1103
|
+
collection: "app.bsky.actor.profile",
|
|
1104
|
+
rkey: "self",
|
|
1105
|
+
record: input.profile,
|
|
1106
|
+
}),
|
|
1107
|
+
});
|
|
1108
|
+
},
|
|
1109
|
+
async listFeeds(input) {
|
|
1110
|
+
const context = nativeContext(input.context, "bluesky.feeds.list");
|
|
1111
|
+
assertNativeAccount(input.account, context, "bluesky.feeds.list");
|
|
1112
|
+
const limit = input.limit ?? 50;
|
|
1113
|
+
if (!Number.isSafeInteger(limit) || limit < 1 || limit > 100)
|
|
1114
|
+
throw new SocialError({
|
|
1115
|
+
code: "invalid_input",
|
|
1116
|
+
operation: "bluesky.feeds.list",
|
|
1117
|
+
message: "Page size must be between 1 and 100.",
|
|
1118
|
+
});
|
|
1119
|
+
const query = new URLSearchParams({
|
|
1120
|
+
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 } : {}),
|
|
1123
|
+
});
|
|
1124
|
+
// oxlint-disable-next-line anti-slop/require-safety-comment-for-type-assertion -- validated boundary or fixture contract.
|
|
1125
|
+
return object(await xrpc(`app.bsky.feed.getSuggestedFeeds?${query}`, context));
|
|
1126
|
+
},
|
|
1127
|
+
async listConversations(input) {
|
|
1128
|
+
const context = nativeContext(input.context, "bluesky.chat.list");
|
|
1129
|
+
assertNativeAccount(input.account, context, "bluesky.chat.list");
|
|
1130
|
+
const limit = input.limit ?? 50;
|
|
1131
|
+
if (!Number.isSafeInteger(limit) || limit < 1 || limit > 100)
|
|
1132
|
+
throw new SocialError({
|
|
1133
|
+
code: "invalid_input",
|
|
1134
|
+
operation: "bluesky.chat.list",
|
|
1135
|
+
message: "Page size must be between 1 and 100.",
|
|
1136
|
+
});
|
|
1137
|
+
const query = new URLSearchParams({
|
|
1138
|
+
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 } : {}),
|
|
1141
|
+
});
|
|
1142
|
+
// oxlint-disable-next-line anti-slop/require-safety-comment-for-type-assertion -- validated boundary or fixture contract.
|
|
1143
|
+
return object(await xrpc(`chat.bsky.convo.listConvos?${query}`, context));
|
|
1144
|
+
},
|
|
1145
|
+
async listMessages(input) {
|
|
1146
|
+
const context = nativeContext(input.context, "bluesky.chat.messages");
|
|
1147
|
+
assertNativeAccount(input.account, context, "bluesky.chat.messages");
|
|
1148
|
+
const limit = input.limit ?? 50;
|
|
1149
|
+
if (!Number.isSafeInteger(limit) || limit < 1 || limit > 100)
|
|
1150
|
+
throw new SocialError({
|
|
1151
|
+
code: "invalid_input",
|
|
1152
|
+
operation: "bluesky.chat.messages",
|
|
1153
|
+
message: "Page size must be between 1 and 100.",
|
|
1154
|
+
});
|
|
1155
|
+
const query = new URLSearchParams({
|
|
1156
|
+
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 } : {}),
|
|
1159
|
+
});
|
|
1160
|
+
// oxlint-disable-next-line anti-slop/require-safety-comment-for-type-assertion -- validated boundary or fixture contract.
|
|
1161
|
+
return object(await xrpc(`chat.bsky.convo.getMessages?convoId=${encodeURIComponent(input.conversationId)}&${query}`, context));
|
|
1162
|
+
},
|
|
1163
|
+
async sendMessage(input) {
|
|
1164
|
+
const context = nativeContext(input.context, "bluesky.chat.send");
|
|
1165
|
+
assertNativeAccount(input.account, context, "bluesky.chat.send");
|
|
1166
|
+
if (!input.text.trim())
|
|
1167
|
+
throw new SocialError({
|
|
1168
|
+
code: "invalid_input",
|
|
1169
|
+
operation: "bluesky.chat.send",
|
|
1170
|
+
message: "Message text is required.",
|
|
1171
|
+
});
|
|
1172
|
+
// oxlint-disable-next-line anti-slop/require-safety-comment-for-type-assertion -- validated boundary or fixture contract.
|
|
1173
|
+
return object(await xrpc("chat.bsky.convo.sendMessage", context, {
|
|
1174
|
+
body: JSON.stringify({ convoId: input.conversationId, message: { text: input.text } }),
|
|
1175
|
+
}));
|
|
1176
|
+
},
|
|
1177
|
+
};
|
|
1178
|
+
const adapter = defineAdapter({
|
|
1179
|
+
id: backend,
|
|
1180
|
+
capabilities,
|
|
1181
|
+
native,
|
|
1182
|
+
graph: {
|
|
1183
|
+
async getProfile(account, input, context) {
|
|
1184
|
+
const value = await native.getProfile({
|
|
1185
|
+
account,
|
|
1186
|
+
...(input.profileId === undefined ? {} : { actor: input.profileId }),
|
|
1187
|
+
...(input.profileId === undefined && input.handle !== undefined
|
|
1188
|
+
? { actor: input.handle }
|
|
1189
|
+
: {}),
|
|
1190
|
+
context,
|
|
1191
|
+
});
|
|
1192
|
+
const actor = object(value);
|
|
1193
|
+
const id = string(actor["did"]);
|
|
1194
|
+
return {
|
|
1195
|
+
ref: profileRef({
|
|
1196
|
+
backend,
|
|
1197
|
+
platform: "bluesky",
|
|
1198
|
+
accountId: account.accountId,
|
|
1199
|
+
profileId: id,
|
|
1200
|
+
}),
|
|
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"] } : {}),
|
|
1207
|
+
native: actor,
|
|
1208
|
+
};
|
|
1209
|
+
},
|
|
1210
|
+
async listRelationships(account, input, context) {
|
|
1211
|
+
const result = input.kind === "following"
|
|
1212
|
+
? await native.getFollows({
|
|
1213
|
+
account,
|
|
1214
|
+
context,
|
|
1215
|
+
...(input.cursor === undefined ? {} : { cursor: input.cursor }),
|
|
1216
|
+
...(input.limit === undefined ? {} : { limit: input.limit }),
|
|
1217
|
+
})
|
|
1218
|
+
: input.kind === "followers"
|
|
1219
|
+
? await native.getFollowers({
|
|
1220
|
+
account,
|
|
1221
|
+
context,
|
|
1222
|
+
...(input.cursor === undefined ? {} : { cursor: input.cursor }),
|
|
1223
|
+
...(input.limit === undefined ? {} : { limit: input.limit }),
|
|
1224
|
+
})
|
|
1225
|
+
: input.kind === "blocked"
|
|
1226
|
+
? await native.getBlocks({
|
|
1227
|
+
account,
|
|
1228
|
+
context,
|
|
1229
|
+
...(input.cursor === undefined ? {} : { cursor: input.cursor }),
|
|
1230
|
+
...(input.limit === undefined ? {} : { limit: input.limit }),
|
|
1231
|
+
})
|
|
1232
|
+
: await native.getMutes({
|
|
1233
|
+
account,
|
|
1234
|
+
context,
|
|
1235
|
+
...(input.cursor === undefined ? {} : { cursor: input.cursor }),
|
|
1236
|
+
...(input.limit === undefined ? {} : { limit: input.limit }),
|
|
1237
|
+
});
|
|
1238
|
+
return {
|
|
1239
|
+
items: result.actors.map((actor) => {
|
|
1240
|
+
const value = object(actor);
|
|
1241
|
+
const id = string(value["did"]);
|
|
1242
|
+
return {
|
|
1243
|
+
profile: profileRef({
|
|
1244
|
+
backend,
|
|
1245
|
+
platform: "bluesky",
|
|
1246
|
+
accountId: account.accountId,
|
|
1247
|
+
profileId: id,
|
|
1248
|
+
}),
|
|
1249
|
+
relationship: input.kind === "following"
|
|
1250
|
+
? "following"
|
|
1251
|
+
: input.kind === "followers"
|
|
1252
|
+
? "follower"
|
|
1253
|
+
: input.kind,
|
|
1254
|
+
};
|
|
1255
|
+
}),
|
|
1256
|
+
...(result.cursor === undefined ? {} : { nextCursor: result.cursor }),
|
|
1257
|
+
};
|
|
1258
|
+
},
|
|
1259
|
+
async follow(target, context) {
|
|
1260
|
+
await native.follow({ account, did: target.profileId, context });
|
|
1261
|
+
return { profile: target, relationship: "following" };
|
|
1262
|
+
},
|
|
1263
|
+
async unfollow(target, context) {
|
|
1264
|
+
const profile = object(await native.getProfile({ account, actor: target.profileId, context }));
|
|
1265
|
+
const viewer = object(profile["viewer"] ?? {});
|
|
1266
|
+
const uri = viewer["following"];
|
|
1267
|
+
if (typeof uri !== "string" || !uri)
|
|
1268
|
+
throw new SocialError({
|
|
1269
|
+
code: "invalid_input",
|
|
1270
|
+
operation: "graph.unfollow",
|
|
1271
|
+
message: "A follow record URI is required to unfollow.",
|
|
1272
|
+
});
|
|
1273
|
+
await native.unfollow({ account, followUri: uri, context });
|
|
1274
|
+
},
|
|
1275
|
+
async block(target, context) {
|
|
1276
|
+
await native.block({ account, did: target.profileId, context });
|
|
1277
|
+
return { profile: target, relationship: "blocked" };
|
|
1278
|
+
},
|
|
1279
|
+
async unblock(target, context) {
|
|
1280
|
+
const profile = object(await native.getProfile({ account, actor: target.profileId, context }));
|
|
1281
|
+
const viewer = object(profile["viewer"] ?? {});
|
|
1282
|
+
const uri = viewer["blocking"];
|
|
1283
|
+
if (typeof uri !== "string" || !uri)
|
|
1284
|
+
throw new SocialError({
|
|
1285
|
+
code: "invalid_input",
|
|
1286
|
+
operation: "graph.unblock",
|
|
1287
|
+
message: "A block record URI is required to unblock.",
|
|
1288
|
+
});
|
|
1289
|
+
await native.unblock({ account, blockUri: uri, context });
|
|
1290
|
+
},
|
|
1291
|
+
async mute(target, context) {
|
|
1292
|
+
await native.mute({ account, did: target.profileId, context });
|
|
1293
|
+
return { profile: target, relationship: "muted" };
|
|
1294
|
+
},
|
|
1295
|
+
async unmute(target, context) {
|
|
1296
|
+
await native.unmute({ account, did: target.profileId, context });
|
|
1297
|
+
},
|
|
1298
|
+
},
|
|
1299
|
+
accounts: {
|
|
1300
|
+
async list(_input, context) {
|
|
1301
|
+
const session = await verifiedSession(context);
|
|
1302
|
+
return {
|
|
1303
|
+
items: [
|
|
1304
|
+
{
|
|
1305
|
+
ref: account,
|
|
1306
|
+
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 }),
|
|
1309
|
+
status: "connected",
|
|
1310
|
+
},
|
|
1311
|
+
],
|
|
1312
|
+
};
|
|
1313
|
+
},
|
|
1314
|
+
async get(ref, context) {
|
|
1315
|
+
if (!accountMatches(ref, auth, backend))
|
|
1316
|
+
throw new SocialError({
|
|
1317
|
+
code: "unauthorized",
|
|
1318
|
+
operation: "bluesky.accounts.get",
|
|
1319
|
+
message: "The account reference does not belong to this Bluesky adapter.",
|
|
1320
|
+
account: ref,
|
|
1321
|
+
});
|
|
1322
|
+
const session = await verifiedSession(context);
|
|
1323
|
+
return {
|
|
1324
|
+
ref: account,
|
|
1325
|
+
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 }),
|
|
1328
|
+
status: "connected",
|
|
1329
|
+
};
|
|
1330
|
+
},
|
|
1331
|
+
},
|
|
1332
|
+
search: {
|
|
1333
|
+
async posts(account, input, context) {
|
|
1334
|
+
if (!accountMatches(account, auth, backend))
|
|
1335
|
+
throw new SocialError({
|
|
1336
|
+
code: "unauthorized",
|
|
1337
|
+
operation: "search.posts",
|
|
1338
|
+
message: "The account reference does not belong to this adapter.",
|
|
1339
|
+
account,
|
|
1340
|
+
});
|
|
1341
|
+
const result = await native.searchPosts({
|
|
1342
|
+
account,
|
|
1343
|
+
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 }),
|
|
1349
|
+
context,
|
|
1350
|
+
});
|
|
1351
|
+
return {
|
|
1352
|
+
items: result.posts,
|
|
1353
|
+
...(result.cursor === undefined ? {} : { nextCursor: result.cursor }),
|
|
1354
|
+
...(result.hitsTotal === undefined ? {} : { metadata: { hitsTotal: result.hitsTotal } }),
|
|
1355
|
+
};
|
|
1356
|
+
},
|
|
1357
|
+
},
|
|
1358
|
+
notifications: {
|
|
1359
|
+
async list(account, input, context) {
|
|
1360
|
+
if (!accountMatches(account, auth, backend))
|
|
1361
|
+
throw new SocialError({
|
|
1362
|
+
code: "unauthorized",
|
|
1363
|
+
operation: "bluesky.notifications.list",
|
|
1364
|
+
message: "The account reference does not belong to this adapter.",
|
|
1365
|
+
account,
|
|
1366
|
+
});
|
|
1367
|
+
const response = await native.listNotifications({
|
|
1368
|
+
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 }),
|
|
1373
|
+
context,
|
|
1374
|
+
});
|
|
1375
|
+
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"];
|
|
1383
|
+
return {
|
|
1384
|
+
items,
|
|
1385
|
+
// oxlint-disable-next-line anti-slop/no-runtime-typeof -- validated boundary or fixture contract.
|
|
1386
|
+
...(typeof cursor === "string" ? { nextCursor: cursor } : {}),
|
|
1387
|
+
};
|
|
1388
|
+
},
|
|
1389
|
+
async markSeen(account, input, context) {
|
|
1390
|
+
if (!accountMatches(account, auth, backend))
|
|
1391
|
+
throw new SocialError({
|
|
1392
|
+
code: "unauthorized",
|
|
1393
|
+
operation: "bluesky.notifications.seen",
|
|
1394
|
+
message: "The account reference does not belong to this adapter.",
|
|
1395
|
+
account,
|
|
1396
|
+
});
|
|
1397
|
+
await native.markNotificationsSeen({
|
|
1398
|
+
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 }),
|
|
1401
|
+
context,
|
|
1402
|
+
});
|
|
1403
|
+
},
|
|
1404
|
+
},
|
|
1405
|
+
posts: {
|
|
1406
|
+
async list(account, input, context) {
|
|
1407
|
+
if (!accountMatches(account, auth, backend))
|
|
1408
|
+
throw new SocialError({
|
|
1409
|
+
code: "unauthorized",
|
|
1410
|
+
operation: "bluesky.posts.list",
|
|
1411
|
+
message: "The account reference does not belong to this adapter.",
|
|
1412
|
+
});
|
|
1413
|
+
const limit = input.limit ?? 50;
|
|
1414
|
+
if (!Number.isSafeInteger(limit) || limit < 1 || limit > 100)
|
|
1415
|
+
throw new SocialError({
|
|
1416
|
+
code: "invalid_input",
|
|
1417
|
+
operation: "posts.read",
|
|
1418
|
+
message: "Bluesky author-feed page size must be between 1 and 100.",
|
|
1419
|
+
});
|
|
1420
|
+
const query = new URLSearchParams({
|
|
1421
|
+
actor: auth.did,
|
|
1422
|
+
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 }),
|
|
1425
|
+
});
|
|
1426
|
+
const response = object(await xrpc(`app.bsky.feed.getAuthorFeed?${query.toString()}`, context));
|
|
1427
|
+
const feed = array(response["feed"]).map((value) => {
|
|
1428
|
+
const entry = object(value);
|
|
1429
|
+
const post = object(entry["post"]);
|
|
1430
|
+
const safePost = {
|
|
1431
|
+
...publicFields(post, [
|
|
1432
|
+
"uri",
|
|
1433
|
+
"cid",
|
|
1434
|
+
"likeCount",
|
|
1435
|
+
"repostCount",
|
|
1436
|
+
"replyCount",
|
|
1437
|
+
"quoteCount",
|
|
1438
|
+
"indexedAt",
|
|
1439
|
+
"labels",
|
|
1440
|
+
]),
|
|
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"]), [
|
|
1446
|
+
"did",
|
|
1447
|
+
"handle",
|
|
1448
|
+
"displayName",
|
|
1449
|
+
"avatar",
|
|
1450
|
+
]),
|
|
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"]), [
|
|
1457
|
+
"text",
|
|
1458
|
+
"facets",
|
|
1459
|
+
"createdAt",
|
|
1460
|
+
"reply",
|
|
1461
|
+
"embed",
|
|
1462
|
+
]),
|
|
1463
|
+
}),
|
|
1464
|
+
};
|
|
1465
|
+
// oxlint-disable-next-line anti-slop/require-safety-comment-for-type-assertion -- validated boundary or fixture contract.
|
|
1466
|
+
return {
|
|
1467
|
+
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"]) }),
|
|
1472
|
+
};
|
|
1473
|
+
});
|
|
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
|
+
return {
|
|
1477
|
+
// oxlint-disable-next-line anti-slop/require-safety-comment-for-type-assertion -- validated boundary or fixture contract.
|
|
1478
|
+
items: feed,
|
|
1479
|
+
// oxlint-disable-next-line anti-slop/no-conditional-empty-object-spread -- validated boundary or fixture contract.
|
|
1480
|
+
...(cursor === undefined ? {} : { nextCursor: cursor }),
|
|
1481
|
+
};
|
|
1482
|
+
},
|
|
1483
|
+
prepareTarget(target) {
|
|
1484
|
+
const issues = [];
|
|
1485
|
+
const text = target.content.text ?? "";
|
|
1486
|
+
try {
|
|
1487
|
+
richTextOptions(text, target.options);
|
|
1488
|
+
}
|
|
1489
|
+
catch (error) {
|
|
1490
|
+
issues.push({
|
|
1491
|
+
code: "bluesky.richtext",
|
|
1492
|
+
message: error instanceof Error ? error.message : "Invalid Bluesky rich text options.",
|
|
1493
|
+
severity: "error",
|
|
1494
|
+
});
|
|
1495
|
+
}
|
|
1496
|
+
if (new TextEncoder().encode(text).byteLength > 3000 || graphemeCount(text) > 300)
|
|
1497
|
+
issues.push({
|
|
1498
|
+
code: "text.too_long",
|
|
1499
|
+
message: "Bluesky posts must be at most 300 graphemes and 3,000 UTF-8 bytes.",
|
|
1500
|
+
severity: "error",
|
|
1501
|
+
});
|
|
1502
|
+
if (target.account.platform !== "bluesky")
|
|
1503
|
+
issues.push({
|
|
1504
|
+
code: "platform.unsupported",
|
|
1505
|
+
message: "Bluesky adapter received a different platform.",
|
|
1506
|
+
severity: "error",
|
|
1507
|
+
});
|
|
1508
|
+
if (target.schedule !== undefined)
|
|
1509
|
+
issues.push({
|
|
1510
|
+
code: "schedule.unsupported",
|
|
1511
|
+
message: "Bluesky scheduling requires an application-owned job runner.",
|
|
1512
|
+
severity: "error",
|
|
1513
|
+
});
|
|
1514
|
+
if (target.content.media?.some((media) => media.kind !== "image"))
|
|
1515
|
+
issues.push({
|
|
1516
|
+
code: "media.unsupported",
|
|
1517
|
+
message: "Bluesky direct publishing supports images, not video.",
|
|
1518
|
+
severity: "error",
|
|
1519
|
+
});
|
|
1520
|
+
if ((target.content.media?.length ?? 0) > MAX_IMAGES)
|
|
1521
|
+
issues.push({
|
|
1522
|
+
code: "media.too_many",
|
|
1523
|
+
message: `Bluesky supports at most ${MAX_IMAGES} images per post.`,
|
|
1524
|
+
severity: "error",
|
|
1525
|
+
});
|
|
1526
|
+
return issues;
|
|
1527
|
+
},
|
|
1528
|
+
async publishTarget(target, context) {
|
|
1529
|
+
if (!accountMatches(target.account, auth, backend))
|
|
1530
|
+
throw new SocialError({
|
|
1531
|
+
code: "unauthorized",
|
|
1532
|
+
operation: "bluesky.publish",
|
|
1533
|
+
message: "The account reference does not belong to this adapter.",
|
|
1534
|
+
account: target.account,
|
|
1535
|
+
});
|
|
1536
|
+
try {
|
|
1537
|
+
const content = target.content;
|
|
1538
|
+
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));
|
|
1553
|
+
if (target.replyTo !== undefined) {
|
|
1554
|
+
if (target.replyTo.kind !== "platform-post")
|
|
1555
|
+
throw new SocialError({
|
|
1556
|
+
code: "unsupported_capability",
|
|
1557
|
+
operation: "bluesky.reply",
|
|
1558
|
+
message: "Bluesky replies require a platform post reference.",
|
|
1559
|
+
});
|
|
1560
|
+
const parent = await native.getPost({
|
|
1561
|
+
uri: target.replyTo.native?.["uri"]?.toString() ?? target.replyTo.postId,
|
|
1562
|
+
context,
|
|
1563
|
+
});
|
|
1564
|
+
const parentRecord = object(parent["record"]);
|
|
1565
|
+
const parentRef = target.replyTo.native === undefined
|
|
1566
|
+
? { uri: string(parent["uri"]), cid: string(parent["cid"]) }
|
|
1567
|
+
: {
|
|
1568
|
+
uri: string(target.replyTo.native["uri"]),
|
|
1569
|
+
cid: string(target.replyTo.native["cid"]),
|
|
1570
|
+
};
|
|
1571
|
+
const rootValue = parentRecord["reply"];
|
|
1572
|
+
const root = rootValue === undefined ? parentRef : object(object(rootValue)["root"]);
|
|
1573
|
+
Object.assign(record, { reply: { root, parent: parentRef } });
|
|
1574
|
+
}
|
|
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.
|
|
1577
|
+
const blobs = [];
|
|
1578
|
+
for (const media of content.media) {
|
|
1579
|
+
const source = await readMedia(media.source, fetcher, context, allowMediaHost);
|
|
1580
|
+
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], {
|
|
1583
|
+
type: source.mimeType,
|
|
1584
|
+
}),
|
|
1585
|
+
headers: { "Content-Type": source.mimeType },
|
|
1586
|
+
}));
|
|
1587
|
+
blobs.push(object(blob["blob"]));
|
|
1588
|
+
}
|
|
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
|
+
});
|
|
1598
|
+
}
|
|
1599
|
+
const response = object(await xrpc("com.atproto.repo.createRecord", context, {
|
|
1600
|
+
body: JSON.stringify({ repo: auth.did, collection: "app.bsky.feed.post", record }),
|
|
1601
|
+
headers: { "Content-Type": "application/json" },
|
|
1602
|
+
}));
|
|
1603
|
+
const created = postRef(response);
|
|
1604
|
+
const delivery = {
|
|
1605
|
+
kind: "delivery",
|
|
1606
|
+
version: 1,
|
|
1607
|
+
backend,
|
|
1608
|
+
deliveryId: created.uri,
|
|
1609
|
+
platform: "bluesky",
|
|
1610
|
+
accountId: auth.did,
|
|
1611
|
+
};
|
|
1612
|
+
return {
|
|
1613
|
+
state: "published",
|
|
1614
|
+
targetIndex: target.targetIndex,
|
|
1615
|
+
account: target.account,
|
|
1616
|
+
delivery,
|
|
1617
|
+
post: {
|
|
1618
|
+
...platformPostRef({
|
|
1619
|
+
backend,
|
|
1620
|
+
platform: "bluesky",
|
|
1621
|
+
accountId: auth.did,
|
|
1622
|
+
postId: created.uri,
|
|
1623
|
+
}),
|
|
1624
|
+
native: { uri: created.uri, cid: created.cid },
|
|
1625
|
+
},
|
|
1626
|
+
observedAt: new Date().toISOString(),
|
|
1627
|
+
};
|
|
1628
|
+
}
|
|
1629
|
+
catch (error) {
|
|
1630
|
+
throw operationError("bluesky.publish", error);
|
|
1631
|
+
}
|
|
1632
|
+
},
|
|
1633
|
+
async get(ref, context) {
|
|
1634
|
+
if (ref.backend !== backend || ref.platform !== "bluesky" || ref.accountId !== auth.did)
|
|
1635
|
+
throw new SocialError({
|
|
1636
|
+
code: "unauthorized",
|
|
1637
|
+
operation: "bluesky.posts.get",
|
|
1638
|
+
message: "The account reference does not belong to this adapter.",
|
|
1639
|
+
});
|
|
1640
|
+
return native.getPost({ uri: ref.postId, context });
|
|
1641
|
+
},
|
|
1642
|
+
async removeFromPlatform(ref, context) {
|
|
1643
|
+
if (!accountMatches(connectedAccountRef({
|
|
1644
|
+
backend: ref.backend,
|
|
1645
|
+
platform: "bluesky",
|
|
1646
|
+
accountId: ref.accountId,
|
|
1647
|
+
}), auth, backend))
|
|
1648
|
+
throw new SocialError({
|
|
1649
|
+
code: "unauthorized",
|
|
1650
|
+
operation: "posts.removeFromPlatform",
|
|
1651
|
+
message: "The post reference does not belong to this Bluesky adapter.",
|
|
1652
|
+
});
|
|
1653
|
+
await native.deletePost({
|
|
1654
|
+
account,
|
|
1655
|
+
post: {
|
|
1656
|
+
uri: ref.native?.["uri"]?.toString() ?? ref.postId,
|
|
1657
|
+
cid: ref.native?.["cid"]?.toString() ?? "",
|
|
1658
|
+
},
|
|
1659
|
+
context,
|
|
1660
|
+
});
|
|
1661
|
+
},
|
|
1662
|
+
},
|
|
1663
|
+
comments: {
|
|
1664
|
+
async list(post, input, context) {
|
|
1665
|
+
if (input.cursor !== undefined)
|
|
1666
|
+
throw new SocialError({
|
|
1667
|
+
code: "unsupported_capability",
|
|
1668
|
+
operation: "comments.read",
|
|
1669
|
+
message: "This adapter does not expose upstream comment pagination.",
|
|
1670
|
+
});
|
|
1671
|
+
const thread = await native.getPostThread({ uri: post.postId, context });
|
|
1672
|
+
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
|
+
return { items: replies.map((reply) => object(reply)) };
|
|
1675
|
+
},
|
|
1676
|
+
async reply(comment, content, context) {
|
|
1677
|
+
const outcome = await adapter.posts?.publishTarget({
|
|
1678
|
+
targetIndex: 0,
|
|
1679
|
+
targetKey: comment.backend + ":" + comment.platform + ":" + comment.accountId,
|
|
1680
|
+
account: connectedAccountRef({
|
|
1681
|
+
backend: comment.backend,
|
|
1682
|
+
platform: "bluesky",
|
|
1683
|
+
accountId: comment.accountId,
|
|
1684
|
+
}),
|
|
1685
|
+
content: { text: content.text },
|
|
1686
|
+
replyTo: platformPostRef({
|
|
1687
|
+
backend: comment.backend,
|
|
1688
|
+
platform: "bluesky",
|
|
1689
|
+
accountId: comment.accountId,
|
|
1690
|
+
postId: comment.commentId,
|
|
1691
|
+
}),
|
|
1692
|
+
}, context);
|
|
1693
|
+
if (outcome?.state !== "published")
|
|
1694
|
+
throw new SocialError({
|
|
1695
|
+
code: "upstream_failure",
|
|
1696
|
+
operation: "bluesky.comments.reply",
|
|
1697
|
+
message: "Bluesky did not confirm the comment reply.",
|
|
1698
|
+
});
|
|
1699
|
+
return {
|
|
1700
|
+
kind: "comment",
|
|
1701
|
+
version: 1,
|
|
1702
|
+
backend: comment.backend,
|
|
1703
|
+
platform: "bluesky",
|
|
1704
|
+
accountId: comment.accountId,
|
|
1705
|
+
postId: comment.postId,
|
|
1706
|
+
commentId: outcome.post.postId,
|
|
1707
|
+
};
|
|
1708
|
+
},
|
|
1709
|
+
},
|
|
1710
|
+
analytics: {
|
|
1711
|
+
async getPostMetrics(post, context) {
|
|
1712
|
+
const value = await native.getPost({ uri: post.postId, context });
|
|
1713
|
+
const record = object(value);
|
|
1714
|
+
const fetchedAt = new Date().toISOString();
|
|
1715
|
+
const metrics = [];
|
|
1716
|
+
for (const [name, field] of [
|
|
1717
|
+
["likes", "likeCount"],
|
|
1718
|
+
["reposts", "repostCount"],
|
|
1719
|
+
["replies", "replyCount"],
|
|
1720
|
+
["quotes", "quoteCount"],
|
|
1721
|
+
]) {
|
|
1722
|
+
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))
|
|
1725
|
+
metrics.push({
|
|
1726
|
+
name,
|
|
1727
|
+
value: count,
|
|
1728
|
+
unit: "count",
|
|
1729
|
+
period: "lifetime",
|
|
1730
|
+
fetchedAt,
|
|
1731
|
+
freshness: "unknown",
|
|
1732
|
+
source: "app.bsky.feed.getPosts",
|
|
1733
|
+
});
|
|
1734
|
+
}
|
|
1735
|
+
return metrics;
|
|
1736
|
+
},
|
|
1737
|
+
async getAccountMetrics(accountRef, context) {
|
|
1738
|
+
// https://docs.bsky.app/docs/api/app-bsky-actor-get-profile
|
|
1739
|
+
if (!accountMatches(accountRef, auth, backend))
|
|
1740
|
+
throw new SocialError({
|
|
1741
|
+
code: "unauthorized",
|
|
1742
|
+
operation: "bluesky.analytics.account",
|
|
1743
|
+
message: "Account reference does not belong to this Bluesky adapter.",
|
|
1744
|
+
});
|
|
1745
|
+
const profile = object(await xrpc(`app.bsky.actor.getProfile?actor=${encodeURIComponent(auth.did)}`, context));
|
|
1746
|
+
if (profile["did"] !== auth.did)
|
|
1747
|
+
throw new SocialError({
|
|
1748
|
+
code: "unauthorized",
|
|
1749
|
+
operation: "bluesky.analytics.account",
|
|
1750
|
+
message: "Bluesky profile identity does not match the requested DID.",
|
|
1751
|
+
});
|
|
1752
|
+
const fetchedAt = new Date().toISOString();
|
|
1753
|
+
return [
|
|
1754
|
+
["followers", profile["followersCount"]],
|
|
1755
|
+
["following", profile["followsCount"]],
|
|
1756
|
+
["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)
|
|
1760
|
+
? [
|
|
1761
|
+
{
|
|
1762
|
+
name,
|
|
1763
|
+
value,
|
|
1764
|
+
unit: "count",
|
|
1765
|
+
period: "lifetime",
|
|
1766
|
+
fetchedAt,
|
|
1767
|
+
freshness: "unknown",
|
|
1768
|
+
source: "app.bsky.actor.getProfile",
|
|
1769
|
+
},
|
|
1770
|
+
]
|
|
1771
|
+
: []);
|
|
1772
|
+
},
|
|
1773
|
+
},
|
|
1774
|
+
});
|
|
1775
|
+
return adapter;
|
|
1776
|
+
}
|
|
1777
|
+
//# sourceMappingURL=bluesky.js.map
|