Tactical Media Broadcast Hub — initial release
Self-hosted multi-platform broadcast newsroom for activist collectives: publishing pipeline with group approval and DST-proof scheduling, TweetDeck-style column wall with hostile-content-safe rendering, live control room with Auto Director, bridged chat governance with polls and quorum, distributed sandboxed transcode workers, encrypted content-addressed archive, monthly analytics. MIT licensed. See README.md and docs/.
This commit is contained in:
commit
aa5e262f3a
880 changed files with 111042 additions and 0 deletions
354
packages/connector-bluesky/src/contract.test.ts
Normal file
354
packages/connector-bluesky/src/contract.test.ts
Normal file
|
|
@ -0,0 +1,354 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import type { ConnectorConnection, MediaAsset, NormalizedPost } from "@tactical-media-broadcast-hub/core";
|
||||
import type { CredentialAccessor } from "@tactical-media-broadcast-hub/connector-sdk";
|
||||
import { connectorContractTests } from "@tactical-media-broadcast-hub/connector-sdk/testing";
|
||||
import { createBlueskyConnector } from "./index.js";
|
||||
|
||||
const DID = "did:plc:alice123";
|
||||
const HANDLE = "alice.bsky.social";
|
||||
const ACCESS_JWT = "access-jwt-for-tests";
|
||||
const REFRESH_JWT = "refresh-jwt-for-tests";
|
||||
const SERVICE = "https://bsky.social";
|
||||
|
||||
const connection: ConnectorConnection = {
|
||||
id: "conn-bluesky",
|
||||
workspaceId: "ws-1",
|
||||
provider: "bluesky",
|
||||
channelId: DID,
|
||||
credentialId: { kind: "credential-ref", id: "cred-bluesky" },
|
||||
fields: { identifier: HANDLE, service: SERVICE },
|
||||
createdAt: new Date(),
|
||||
};
|
||||
|
||||
interface StubCall {
|
||||
url: URL;
|
||||
init: RequestInit | undefined;
|
||||
}
|
||||
|
||||
interface StubRecord {
|
||||
uri: string;
|
||||
cid: string;
|
||||
collection: string;
|
||||
record: Record<string, unknown>;
|
||||
}
|
||||
|
||||
interface StubState {
|
||||
calls: StubCall[];
|
||||
records: StubRecord[];
|
||||
nextRecord: number;
|
||||
}
|
||||
|
||||
class TestCredentials implements CredentialAccessor {
|
||||
readonly values = new Map<string, string>([
|
||||
[`${connection.id}:access_token`, ACCESS_JWT],
|
||||
[`${connection.id}:did`, DID],
|
||||
]);
|
||||
|
||||
private key(subject: ConnectorConnection, name: string): string {
|
||||
return `${subject.id}:${name}`;
|
||||
}
|
||||
|
||||
async getAccessToken(subject: ConnectorConnection): Promise<string> {
|
||||
const value = this.values.get(this.key(subject, "access_token"));
|
||||
if (value === undefined) throw new Error("missing access token");
|
||||
return value;
|
||||
}
|
||||
|
||||
async getSecret(subject: ConnectorConnection, key: string): Promise<string> {
|
||||
const value = this.values.get(this.key(subject, key));
|
||||
if (value === undefined) throw new Error(`missing ${key}`);
|
||||
return value;
|
||||
}
|
||||
|
||||
async put(subject: ConnectorConnection, key: string, value: string): Promise<void> {
|
||||
this.values.set(this.key(subject, key), value);
|
||||
}
|
||||
}
|
||||
|
||||
function json(value: unknown, status = 200): Response {
|
||||
return new Response(JSON.stringify(value), {
|
||||
status,
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
function requestUrl(input: Parameters<typeof fetch>[0]): URL {
|
||||
if (typeof input === "string") return new URL(input);
|
||||
if (input instanceof URL) return input;
|
||||
return new URL(input.url);
|
||||
}
|
||||
|
||||
function jsonBody(init: RequestInit | undefined): Record<string, unknown> {
|
||||
return JSON.parse(String(init?.body ?? "{}")) as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function stubXrpc(state: StubState): typeof fetch {
|
||||
return async (input: Parameters<typeof fetch>[0], init?: RequestInit): Promise<Response> => {
|
||||
const url = requestUrl(input);
|
||||
state.calls.push({ url, init });
|
||||
const method = url.pathname.replace("/xrpc/", "");
|
||||
|
||||
if (method === "com.atproto.server.createSession") {
|
||||
const body = jsonBody(init);
|
||||
if (body.identifier !== HANDLE || body.password !== "app-pass-1234") {
|
||||
return json({ error: "AuthenticationRequired", message: "bad credentials" }, 401);
|
||||
}
|
||||
return json({ accessJwt: ACCESS_JWT, refreshJwt: REFRESH_JWT, did: DID });
|
||||
}
|
||||
|
||||
expect(init?.headers).toMatchObject({ authorization: `Bearer ${ACCESS_JWT}` });
|
||||
|
||||
if (method === "app.bsky.actor.getProfile") {
|
||||
return json({
|
||||
did: DID,
|
||||
handle: HANDLE,
|
||||
displayName: "Alice Example",
|
||||
avatar: "https://cdn.example.test/alice.jpg",
|
||||
});
|
||||
}
|
||||
if (method === "com.atproto.repo.createRecord") {
|
||||
const body = jsonBody(init);
|
||||
const collection = String(body.collection);
|
||||
const record = body.record as Record<string, unknown>;
|
||||
const rkey = `record-${state.nextRecord}`;
|
||||
state.nextRecord += 1;
|
||||
const created: StubRecord = {
|
||||
uri: `at://${DID}/${collection}/${rkey}`,
|
||||
cid: `bafy-${rkey}`,
|
||||
collection,
|
||||
record,
|
||||
};
|
||||
state.records.unshift(created);
|
||||
return json({ uri: created.uri, cid: created.cid });
|
||||
}
|
||||
if (method === "com.atproto.repo.deleteRecord") {
|
||||
const body = jsonBody(init);
|
||||
const collection = String(body.collection);
|
||||
const rkey = String(body.rkey);
|
||||
state.records = state.records.filter(
|
||||
(record) => record.collection !== collection || !record.uri.endsWith(`/${rkey}`),
|
||||
);
|
||||
return json({});
|
||||
}
|
||||
if (method === "com.atproto.repo.uploadBlob") {
|
||||
expect(init?.body).toBeInstanceOf(Blob);
|
||||
expect(init?.headers).toMatchObject({ "content-type": "image/png" });
|
||||
return json({
|
||||
blob: {
|
||||
$type: "blob",
|
||||
ref: { $link: "bafy-image-1" },
|
||||
mimeType: "image/png",
|
||||
size: 4,
|
||||
},
|
||||
});
|
||||
}
|
||||
if (method === "app.bsky.feed.getTimeline") {
|
||||
const feed = state.records
|
||||
.filter((record) => record.collection === "app.bsky.feed.post")
|
||||
.map((stored) => ({
|
||||
post: {
|
||||
uri: stored.uri,
|
||||
cid: stored.cid,
|
||||
author: { did: DID, handle: HANDLE, displayName: "Alice Example" },
|
||||
record: stored.record,
|
||||
likeCount: 3,
|
||||
repostCount: 2,
|
||||
replyCount: 1,
|
||||
},
|
||||
}));
|
||||
return json({ feed, cursor: feed.length > 0 ? "timeline-next" : undefined });
|
||||
}
|
||||
if (method === "app.bsky.feed.getPostThread") {
|
||||
return json({ thread: { post: {}, replies: [] } });
|
||||
}
|
||||
return json({ error: "UnknownMethod", message: method }, 400);
|
||||
};
|
||||
}
|
||||
|
||||
interface Harness {
|
||||
state: StubState;
|
||||
credentials: TestCredentials;
|
||||
connector: ReturnType<typeof createBlueskyConnector>;
|
||||
}
|
||||
|
||||
function createHarness(): Harness {
|
||||
const state: StubState = { calls: [], records: [], nextRecord: 1 };
|
||||
const credentials = new TestCredentials();
|
||||
const connector = createBlueskyConnector({
|
||||
fetchImpl: stubXrpc(state),
|
||||
credentials,
|
||||
requestsPerSecond: 1000,
|
||||
sleep: async () => Promise.resolve(),
|
||||
loadAsset: async (assetId) => mediaAsset(assetId),
|
||||
openAsset: async () => ({ bytes: new Uint8Array([137, 80, 78, 71]), filename: "photo.png" }),
|
||||
});
|
||||
return { state, credentials, connector };
|
||||
}
|
||||
|
||||
function post(text: string, mediaAssetIds: string[] = []): NormalizedPost {
|
||||
return {
|
||||
text,
|
||||
mediaAssetIds,
|
||||
visibility: "public",
|
||||
providerMetadata: {},
|
||||
};
|
||||
}
|
||||
|
||||
function mediaAsset(id: string): MediaAsset {
|
||||
return {
|
||||
id,
|
||||
workspaceId: "ws-1",
|
||||
storageKey: `assets/${id}`,
|
||||
mimeType: "image/png",
|
||||
sizeBytes: 4,
|
||||
width: 100,
|
||||
height: 50,
|
||||
createdAt: new Date(),
|
||||
};
|
||||
}
|
||||
|
||||
connectorContractTests(createHarness().connector, { connection });
|
||||
|
||||
describe("bluesky connector", () => {
|
||||
it("declares the AT Protocol connection fields and capabilities", () => {
|
||||
const manifest = createHarness().connector.manifest();
|
||||
expect(manifest).toMatchObject({
|
||||
id: "bluesky",
|
||||
displayName: "Bluesky",
|
||||
capabilities: {
|
||||
publishText: true,
|
||||
publishImages: true,
|
||||
feed: true,
|
||||
mentions: true,
|
||||
replies: true,
|
||||
comments: true,
|
||||
amplifyRepost: true,
|
||||
amplifyQuote: true,
|
||||
deletePublication: true,
|
||||
directMessages: false,
|
||||
webhooks: false,
|
||||
},
|
||||
});
|
||||
expect(manifest.connectionFields).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ name: "identifier", secret: false, required: true }),
|
||||
expect.objectContaining({ name: "appKey", secret: true, required: true }),
|
||||
expect.objectContaining({ name: "service", secret: false, required: false }),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it("creates a session, stores JWTs, and resolves the profile during authorize", async () => {
|
||||
const { connector, credentials, state } = createHarness();
|
||||
const result = await connector.authorize({
|
||||
redirectUri: "https://app.example.test/connect/callback",
|
||||
workspaceId: "ws-auth",
|
||||
state: "csrf-state",
|
||||
fields: { identifier: HANDLE, appPassword: "app-pass-1234" },
|
||||
providerMetadata: { connectionId: "conn-authorized" },
|
||||
});
|
||||
|
||||
expect(result.connection).toMatchObject({
|
||||
id: "conn-authorized",
|
||||
provider: "bluesky",
|
||||
channelId: DID,
|
||||
fields: { identifier: HANDLE, service: SERVICE },
|
||||
});
|
||||
expect(result.connection.fields).not.toHaveProperty("appPassword");
|
||||
expect(result.identity).toMatchObject({
|
||||
provider: "bluesky",
|
||||
remoteId: DID,
|
||||
handle: HANDLE,
|
||||
displayName: "Alice Example",
|
||||
});
|
||||
expect(credentials.values.get("conn-authorized:access_token")).toBe(ACCESS_JWT);
|
||||
expect(credentials.values.get("conn-authorized:refresh_token")).toBe(REFRESH_JWT);
|
||||
expect(credentials.values.get("conn-authorized:did")).toBe(DID);
|
||||
expect(state.calls.map((call) => call.url.pathname)).toEqual([
|
||||
"/xrpc/com.atproto.server.createSession",
|
||||
"/xrpc/app.bsky.actor.getProfile",
|
||||
]);
|
||||
});
|
||||
|
||||
it("publishes a post and reads it back through timeline cursor paging", async () => {
|
||||
const { connector, state } = createHarness();
|
||||
const publication = await connector.publish(connection, post("Hello from the newsroom"));
|
||||
const page = await connector.getFeed(connection, { kind: "home", limit: 25 });
|
||||
|
||||
expect(publication.remoteId).toMatch(/^at:\/\/did:plc:alice123\/app\.bsky\.feed\.post\/.+\|bafy-/);
|
||||
expect(publication.providerMetadata).toMatchObject({
|
||||
uri: expect.stringContaining("/app.bsky.feed.post/"),
|
||||
cid: expect.stringMatching(/^bafy-/),
|
||||
});
|
||||
expect(page).toMatchObject({
|
||||
nextCursor: "timeline-next",
|
||||
items: [
|
||||
{
|
||||
remoteId: publication.remoteId,
|
||||
authorHandle: HANDLE,
|
||||
text: "Hello from the newsroom",
|
||||
metrics: { likes: 3, reposts: 2, replies: 1 },
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(state.calls.some((call) => call.url.pathname.endsWith("getTimeline"))).toBe(true);
|
||||
});
|
||||
|
||||
it("roundtrips repost and quote records and removes an amplification", async () => {
|
||||
const { connector, state } = createHarness();
|
||||
const original = await connector.publish(connection, post("Original"));
|
||||
const repost = await connector.amplify(connection, {
|
||||
kind: "repost",
|
||||
targetRemoteId: original.remoteId,
|
||||
});
|
||||
const quote = await connector.amplify(connection, {
|
||||
kind: "quote",
|
||||
targetRemoteId: original.remoteId,
|
||||
commentary: "Worth reading",
|
||||
});
|
||||
|
||||
const repostRecord = state.records.find((record) => record.collection === "app.bsky.feed.repost");
|
||||
expect(repostRecord?.record.subject).toEqual({
|
||||
uri: original.providerMetadata.uri,
|
||||
cid: original.providerMetadata.cid,
|
||||
});
|
||||
const quoteRecord = state.records.find(
|
||||
(record) => record.uri === String(quote.providerMetadata.uri),
|
||||
);
|
||||
expect(quoteRecord?.record).toMatchObject({
|
||||
text: "Worth reading",
|
||||
embed: {
|
||||
$type: "app.bsky.embed.record",
|
||||
record: {
|
||||
uri: original.providerMetadata.uri,
|
||||
cid: original.providerMetadata.cid,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await connector.unamplify(connection, repost.remoteId);
|
||||
expect(state.records.some((record) => record.uri === repostRecord?.uri)).toBe(false);
|
||||
});
|
||||
|
||||
it("uploads raw image bytes and embeds the returned blob in an image post", async () => {
|
||||
const { connector, state } = createHarness();
|
||||
const publication = await connector.publish(connection, post("A chart", ["chart.png"]));
|
||||
const stored = state.records.find(
|
||||
(record) => record.uri === String(publication.providerMetadata.uri),
|
||||
);
|
||||
expect(stored?.record.embed).toEqual({
|
||||
$type: "app.bsky.embed.images",
|
||||
images: [
|
||||
{
|
||||
alt: "",
|
||||
image: {
|
||||
$type: "blob",
|
||||
ref: { $link: "bafy-image-1" },
|
||||
mimeType: "image/png",
|
||||
size: 4,
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
766
packages/connector-bluesky/src/index.ts
Normal file
766
packages/connector-bluesky/src/index.ts
Normal file
|
|
@ -0,0 +1,766 @@
|
|||
/**
|
||||
* Bluesky connector using the AT Protocol XRPC API.
|
||||
*
|
||||
* AT record operations need both a URI and CID. the connector
|
||||
* methods expose one remoteId, so this connector encodes record references as
|
||||
* `<at-uri>|<cid>`. The individual values are also retained in publication
|
||||
* metadata. Delete operations only need the URI and accept either form.
|
||||
*/
|
||||
import { randomUUID } from "node:crypto";
|
||||
import type {
|
||||
Comment,
|
||||
ConnectorCapabilities,
|
||||
ConnectorConnection,
|
||||
CredentialRef,
|
||||
FeedItem,
|
||||
MediaAsset,
|
||||
NormalizedIdentity,
|
||||
NormalizedPost,
|
||||
Page,
|
||||
Publication,
|
||||
RemoteMedia,
|
||||
} from "@tactical-media-broadcast-hub/core";
|
||||
import {
|
||||
assertValidManifest,
|
||||
AuthError,
|
||||
CapabilityNotSupportedError,
|
||||
ConnectorError,
|
||||
InMemoryCredentials,
|
||||
pageOf,
|
||||
RateLimitedHttpClient,
|
||||
type AuthorizationRequest,
|
||||
type AuthorizationResult,
|
||||
type ConnectorManifest,
|
||||
type CredentialAccessor,
|
||||
type PlatformConnector,
|
||||
} from "@tactical-media-broadcast-hub/connector-sdk";
|
||||
|
||||
const PROVIDER = "bluesky";
|
||||
const DEFAULT_SERVICE = "https://bsky.social";
|
||||
const POST_COLLECTION = "app.bsky.feed.post";
|
||||
const REPOST_COLLECTION = "app.bsky.feed.repost";
|
||||
const MAX_IMAGES = 4;
|
||||
|
||||
const SECRET = {
|
||||
accessToken: "access_token",
|
||||
refreshToken: "refresh_token",
|
||||
did: "did",
|
||||
} as const;
|
||||
|
||||
const CAPABILITIES: ConnectorCapabilities = {
|
||||
publishText: true,
|
||||
publishImages: true,
|
||||
feed: true,
|
||||
mentions: true,
|
||||
replies: true,
|
||||
comments: true,
|
||||
amplifyRepost: true,
|
||||
amplifyQuote: true,
|
||||
deletePublication: true,
|
||||
directMessages: false,
|
||||
webhooks: false,
|
||||
};
|
||||
|
||||
export interface BlueskyConnectorOptions {
|
||||
/** fetch override for tests and host applications. */
|
||||
fetchImpl?: typeof fetch;
|
||||
/** The documented default is one request per second. */
|
||||
requestsPerSecond?: number;
|
||||
/** Sleep override for deterministic tests. */
|
||||
sleep?: (ms: number) => Promise<void>;
|
||||
/** Secret store. Default: in-memory for development and contract tests. */
|
||||
credentials?: CredentialAccessor;
|
||||
/** Resolve local media ids used by NormalizedPost. */
|
||||
loadAsset?: (assetId: string) => Promise<MediaAsset>;
|
||||
/** Read media bytes for com.atproto.repo.uploadBlob. */
|
||||
openAsset?: (media: MediaAsset) => Promise<{ bytes: Uint8Array; filename: string }>;
|
||||
}
|
||||
|
||||
export interface BlueskyAuthorizationRequest extends AuthorizationRequest {
|
||||
fields?: Record<string, string>;
|
||||
providerMetadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface BlueskyConnector
|
||||
extends Omit<
|
||||
PlatformConnector,
|
||||
| "authorize"
|
||||
| "publish"
|
||||
| "deletePublication"
|
||||
| "uploadMedia"
|
||||
| "getFeed"
|
||||
| "getComments"
|
||||
| "reply"
|
||||
| "amplify"
|
||||
| "unamplify"
|
||||
> {
|
||||
authorize(input: BlueskyAuthorizationRequest): Promise<AuthorizationResult>;
|
||||
publish: NonNullable<PlatformConnector["publish"]>;
|
||||
deletePublication: NonNullable<PlatformConnector["deletePublication"]>;
|
||||
uploadMedia: NonNullable<PlatformConnector["uploadMedia"]>;
|
||||
getFeed: NonNullable<PlatformConnector["getFeed"]>;
|
||||
getComments: NonNullable<PlatformConnector["getComments"]>;
|
||||
reply: NonNullable<PlatformConnector["reply"]>;
|
||||
amplify: NonNullable<PlatformConnector["amplify"]>;
|
||||
unamplify: NonNullable<PlatformConnector["unamplify"]>;
|
||||
}
|
||||
|
||||
interface SessionResponse {
|
||||
accessJwt: string;
|
||||
refreshJwt: string;
|
||||
did: string;
|
||||
}
|
||||
|
||||
interface ProfileResponse {
|
||||
did: string;
|
||||
handle: string;
|
||||
displayName?: string;
|
||||
avatar?: string;
|
||||
}
|
||||
|
||||
interface RecordReference {
|
||||
uri: string;
|
||||
cid: string;
|
||||
}
|
||||
|
||||
interface StrongRef {
|
||||
uri: string;
|
||||
cid: string;
|
||||
}
|
||||
|
||||
interface BlobReference {
|
||||
$type?: string;
|
||||
ref: { $link: string } | string;
|
||||
mimeType: string;
|
||||
size: number;
|
||||
}
|
||||
|
||||
interface UploadBlobResponse {
|
||||
blob: BlobReference;
|
||||
}
|
||||
|
||||
interface CreateRecordResponse extends RecordReference {
|
||||
validationStatus?: string;
|
||||
}
|
||||
|
||||
interface PostRecord {
|
||||
text: string;
|
||||
createdAt: string;
|
||||
reply?: { root: StrongRef; parent: StrongRef };
|
||||
}
|
||||
|
||||
interface PostView {
|
||||
uri: string;
|
||||
cid: string;
|
||||
author: {
|
||||
did: string;
|
||||
handle: string;
|
||||
displayName?: string;
|
||||
avatar?: string;
|
||||
};
|
||||
record: PostRecord;
|
||||
embed?: unknown;
|
||||
likeCount?: number;
|
||||
repostCount?: number;
|
||||
replyCount?: number;
|
||||
}
|
||||
|
||||
interface FeedViewPost {
|
||||
post: PostView;
|
||||
}
|
||||
|
||||
interface TimelineResponse {
|
||||
feed: FeedViewPost[];
|
||||
cursor?: string;
|
||||
}
|
||||
|
||||
interface SearchResponse {
|
||||
posts: PostView[];
|
||||
cursor?: string;
|
||||
}
|
||||
|
||||
interface NotificationView {
|
||||
uri: string;
|
||||
cid: string;
|
||||
author: PostView["author"];
|
||||
record: PostRecord;
|
||||
reason: string;
|
||||
indexedAt: string;
|
||||
}
|
||||
|
||||
interface NotificationsResponse {
|
||||
notifications: NotificationView[];
|
||||
cursor?: string;
|
||||
}
|
||||
|
||||
interface ThreadViewPost {
|
||||
post: PostView;
|
||||
replies?: ThreadViewPost[];
|
||||
}
|
||||
|
||||
interface PostThreadResponse {
|
||||
thread: ThreadViewPost;
|
||||
}
|
||||
|
||||
interface ParsedAtUri {
|
||||
repo: string;
|
||||
collection: string;
|
||||
rkey: string;
|
||||
}
|
||||
|
||||
function isObject(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null;
|
||||
}
|
||||
|
||||
function stringValue(value: unknown): string | undefined {
|
||||
return typeof value === "string" && value.length > 0 ? value : undefined;
|
||||
}
|
||||
|
||||
function normalizeService(value: string | undefined): string {
|
||||
const raw = value === undefined || value.trim() === "" ? DEFAULT_SERVICE : value.trim();
|
||||
let url: URL;
|
||||
try {
|
||||
url = new URL(raw);
|
||||
} catch (cause) {
|
||||
throw new ConnectorError(`${PROVIDER}: invalid service URL`, PROVIDER, cause);
|
||||
}
|
||||
if (url.protocol !== "https:" && url.protocol !== "http:") {
|
||||
throw new ConnectorError(`${PROVIDER}: service must use http or https`, PROVIDER);
|
||||
}
|
||||
return url.toString().replace(/\/$/, "");
|
||||
}
|
||||
|
||||
function jsonPost(body: Record<string, unknown>): RequestInit {
|
||||
return {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
};
|
||||
}
|
||||
|
||||
function encodeReference(reference: RecordReference): string {
|
||||
return `${reference.uri}|${reference.cid}`;
|
||||
}
|
||||
|
||||
function parseAtUri(uri: string): ParsedAtUri {
|
||||
const match = /^at:\/\/([^/]+)\/([^/]+)\/([^/?#]+)$/.exec(uri);
|
||||
if (match === null) {
|
||||
throw new ConnectorError(`${PROVIDER}: malformed AT record URI "${uri}"`, PROVIDER);
|
||||
}
|
||||
const repo = match[1];
|
||||
const collection = match[2];
|
||||
const rkey = match[3];
|
||||
if (repo === undefined || collection === undefined || rkey === undefined) {
|
||||
throw new ConnectorError(`${PROVIDER}: incomplete AT record URI "${uri}"`, PROVIDER);
|
||||
}
|
||||
return { repo, collection, rkey };
|
||||
}
|
||||
|
||||
function imageUrls(embed: unknown): string[] {
|
||||
if (!isObject(embed) || !Array.isArray(embed.images)) return [];
|
||||
return embed.images.flatMap((image) => {
|
||||
if (!isObject(image)) return [];
|
||||
const fullsize = stringValue(image.fullsize);
|
||||
const thumb = stringValue(image.thumb);
|
||||
return fullsize !== undefined ? [fullsize] : thumb !== undefined ? [thumb] : [];
|
||||
});
|
||||
}
|
||||
|
||||
function blobCid(blob: BlobReference): string {
|
||||
if (typeof blob.ref === "string") return blob.ref;
|
||||
return blob.ref.$link;
|
||||
}
|
||||
|
||||
export function createBlueskyConnector(options: BlueskyConnectorOptions = {}): BlueskyConnector {
|
||||
const client = new RateLimitedHttpClient({
|
||||
requestsPerSecond: options.requestsPerSecond ?? 1,
|
||||
fetchImpl: options.fetchImpl,
|
||||
sleep: options.sleep,
|
||||
});
|
||||
const credentials = options.credentials ?? new InMemoryCredentials();
|
||||
const references = new Map<string, RecordReference>();
|
||||
const replyRoots = new Map<string, StrongRef>();
|
||||
const blobReferences = new Map<string, BlobReference>();
|
||||
const handles = new Map<string, string>();
|
||||
|
||||
const manifest: ConnectorManifest = {
|
||||
id: PROVIDER,
|
||||
displayName: "Bluesky",
|
||||
capabilities: CAPABILITIES,
|
||||
connectionFields: [
|
||||
{
|
||||
name: "identifier",
|
||||
label: "Handle or email",
|
||||
required: true,
|
||||
secret: false,
|
||||
placeholder: "you.bsky.social",
|
||||
},
|
||||
{
|
||||
// The SDK contract forbids "password" in field names so generic UIs
|
||||
// and logs do not mistake this for an account password.
|
||||
name: "appKey",
|
||||
label: "App password (never your account password)",
|
||||
required: true,
|
||||
secret: true,
|
||||
placeholder: "xxxx-xxxx-xxxx-xxxx",
|
||||
},
|
||||
{
|
||||
name: "service",
|
||||
label: "Personal data server",
|
||||
required: false,
|
||||
secret: false,
|
||||
placeholder: DEFAULT_SERVICE,
|
||||
},
|
||||
],
|
||||
oauth: { kind: "token" },
|
||||
docsUrl: "https://docs.bsky.app/docs/category/http-reference",
|
||||
};
|
||||
|
||||
function serviceOf(connection: ConnectorConnection): string {
|
||||
return normalizeService(connection.fields.service);
|
||||
}
|
||||
|
||||
async function send(url: string, init: RequestInit): Promise<Response> {
|
||||
const response = await client.request(PROVIDER, url, {
|
||||
...init,
|
||||
headers: { accept: "application/json", ...init.headers },
|
||||
});
|
||||
if (response.ok) return response;
|
||||
let detail = "";
|
||||
try {
|
||||
const body = (await response.json()) as { error?: string; message?: string };
|
||||
detail = body.message ?? body.error ?? "";
|
||||
} catch {
|
||||
detail = "";
|
||||
}
|
||||
const message = `${PROVIDER}: HTTP ${response.status} ${new URL(url).pathname}${detail ? `: ${detail}` : ""}`;
|
||||
if (response.status === 401 || response.status === 403) {
|
||||
throw new AuthError(message, PROVIDER, false);
|
||||
}
|
||||
throw new ConnectorError(message, PROVIDER, { status: response.status });
|
||||
}
|
||||
|
||||
async function publicJson<T>(service: string, path: string, init: RequestInit): Promise<T> {
|
||||
const response = await send(`${service}/xrpc/${path}`, init);
|
||||
return (await response.json()) as T;
|
||||
}
|
||||
|
||||
async function api<T>(
|
||||
connection: ConnectorConnection,
|
||||
path: string,
|
||||
init: RequestInit = {},
|
||||
): Promise<T> {
|
||||
let token: string;
|
||||
try {
|
||||
token = await credentials.getAccessToken(connection);
|
||||
} catch {
|
||||
throw new AuthError(`${PROVIDER}: no access token stored for ${connection.id}`, PROVIDER, false);
|
||||
}
|
||||
const response = await send(`${serviceOf(connection)}/xrpc/${path}`, {
|
||||
...init,
|
||||
headers: { authorization: `Bearer ${token}`, ...init.headers },
|
||||
});
|
||||
const text = await response.text();
|
||||
return (text.length === 0 ? {} : JSON.parse(text)) as T;
|
||||
}
|
||||
|
||||
async function didOf(connection: ConnectorConnection): Promise<string> {
|
||||
if (connection.channelId.startsWith("did:")) return connection.channelId;
|
||||
try {
|
||||
return await credentials.getSecret(connection, SECRET.did);
|
||||
} catch {
|
||||
throw new AuthError(`${PROVIDER}: no DID stored for ${connection.id}`, PROVIDER, false);
|
||||
}
|
||||
}
|
||||
|
||||
function rememberReference(reference: RecordReference, root?: StrongRef): string {
|
||||
const encoded = encodeReference(reference);
|
||||
references.set(encoded, reference);
|
||||
references.set(reference.uri, reference);
|
||||
if (root !== undefined) {
|
||||
replyRoots.set(encoded, root);
|
||||
replyRoots.set(reference.uri, root);
|
||||
}
|
||||
return encoded;
|
||||
}
|
||||
|
||||
function requireReference(remoteId: string): RecordReference {
|
||||
const remembered = references.get(remoteId);
|
||||
if (remembered !== undefined) return remembered;
|
||||
const divider = remoteId.lastIndexOf("|");
|
||||
if (divider > 0 && divider < remoteId.length - 1) {
|
||||
const reference = { uri: remoteId.slice(0, divider), cid: remoteId.slice(divider + 1) };
|
||||
parseAtUri(reference.uri);
|
||||
references.set(remoteId, reference);
|
||||
references.set(reference.uri, reference);
|
||||
return reference;
|
||||
}
|
||||
throw new ConnectorError(
|
||||
`${PROVIDER}: record reference must include both URI and CID (expected "at://…|cid")`,
|
||||
PROVIDER,
|
||||
);
|
||||
}
|
||||
|
||||
function uriFromRemoteId(remoteId: string): string {
|
||||
const remembered = references.get(remoteId);
|
||||
if (remembered !== undefined) return remembered.uri;
|
||||
const divider = remoteId.lastIndexOf("|");
|
||||
const uri = divider > 0 ? remoteId.slice(0, divider) : remoteId;
|
||||
parseAtUri(uri);
|
||||
return uri;
|
||||
}
|
||||
|
||||
async function createRecord(
|
||||
connection: ConnectorConnection,
|
||||
collection: string,
|
||||
record: Record<string, unknown>,
|
||||
): Promise<CreateRecordResponse> {
|
||||
return api<CreateRecordResponse>(
|
||||
connection,
|
||||
"com.atproto.repo.createRecord",
|
||||
jsonPost({ repo: await didOf(connection), collection, record }),
|
||||
);
|
||||
}
|
||||
|
||||
function remoteUrl(connection: ConnectorConnection, uri: string): string | undefined {
|
||||
if (!uri.includes(`/${POST_COLLECTION}/`)) return undefined;
|
||||
const handle = handles.get(connection.id) ?? connection.fields.identifier;
|
||||
if (handle === undefined || handle.length === 0) return undefined;
|
||||
return `https://bsky.app/profile/${encodeURIComponent(handle)}/post/${encodeURIComponent(parseAtUri(uri).rkey)}`;
|
||||
}
|
||||
|
||||
function toPublication(
|
||||
connection: ConnectorConnection,
|
||||
reference: CreateRecordResponse,
|
||||
draftId: string,
|
||||
publishedAt: Date,
|
||||
providerMetadata: Record<string, unknown> = {},
|
||||
): Publication {
|
||||
const remoteId = rememberReference(reference);
|
||||
const publication: Publication = {
|
||||
id: randomUUID(),
|
||||
workspaceId: connection.workspaceId,
|
||||
draftId,
|
||||
connectionId: connection.id,
|
||||
remoteId,
|
||||
publishedAt,
|
||||
providerMetadata: {
|
||||
uri: reference.uri,
|
||||
cid: reference.cid,
|
||||
collection: parseAtUri(reference.uri).collection,
|
||||
...providerMetadata,
|
||||
},
|
||||
};
|
||||
const url = remoteUrl(connection, reference.uri);
|
||||
if (url !== undefined) publication.remoteUrl = url;
|
||||
return publication;
|
||||
}
|
||||
|
||||
function toFeedItem(post: PostView): FeedItem {
|
||||
const root = post.record.reply?.root;
|
||||
const remoteId = rememberReference(post, root);
|
||||
return {
|
||||
id: post.uri,
|
||||
provider: PROVIDER,
|
||||
remoteId,
|
||||
authorHandle: post.author.handle,
|
||||
text: post.record.text,
|
||||
mediaUrls: imageUrls(post.embed),
|
||||
createdAt: new Date(post.record.createdAt),
|
||||
metrics: {
|
||||
likes: post.likeCount ?? 0,
|
||||
reposts: post.repostCount ?? 0,
|
||||
replies: post.replyCount ?? 0,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function uploadAsset(connection: ConnectorConnection, media: MediaAsset): Promise<RemoteMedia> {
|
||||
if (!media.mimeType.startsWith("image/")) {
|
||||
throw new ConnectorError(`${PROVIDER}: uploadMedia accepts images only`, PROVIDER);
|
||||
}
|
||||
if (options.openAsset === undefined) {
|
||||
throw new ConnectorError(`${PROVIDER}: media upload requires the openAsset option`, PROVIDER);
|
||||
}
|
||||
const { bytes } = await options.openAsset(media);
|
||||
const response = await api<UploadBlobResponse>(
|
||||
connection,
|
||||
"com.atproto.repo.uploadBlob",
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "content-type": media.mimeType },
|
||||
body: new Blob([new Uint8Array(bytes)], { type: media.mimeType }),
|
||||
},
|
||||
);
|
||||
const cid = blobCid(response.blob);
|
||||
blobReferences.set(cid, response.blob);
|
||||
const remote: RemoteMedia = { remoteId: cid, mimeType: response.blob.mimeType };
|
||||
if (media.width !== undefined) remote.width = media.width;
|
||||
if (media.height !== undefined) remote.height = media.height;
|
||||
return remote;
|
||||
}
|
||||
|
||||
async function imagesFor(
|
||||
connection: ConnectorConnection,
|
||||
post: NormalizedPost,
|
||||
): Promise<{ alt: string; image: BlobReference }[]> {
|
||||
if (post.mediaAssetIds.length > MAX_IMAGES) {
|
||||
throw new ConnectorError(`${PROVIDER}: posts support at most ${MAX_IMAGES} images`, PROVIDER);
|
||||
}
|
||||
const altText = isObject(post.providerMetadata.altText) ? post.providerMetadata.altText : {};
|
||||
const images: { alt: string; image: BlobReference }[] = [];
|
||||
for (const assetId of post.mediaAssetIds) {
|
||||
let blob = blobReferences.get(assetId);
|
||||
if (blob === undefined) {
|
||||
if (options.loadAsset === undefined) {
|
||||
throw new ConnectorError(
|
||||
`${PROVIDER}: publishing local media requires the loadAsset option`,
|
||||
PROVIDER,
|
||||
);
|
||||
}
|
||||
const remote = await uploadAsset(connection, await options.loadAsset(assetId));
|
||||
blob = blobReferences.get(remote.remoteId);
|
||||
}
|
||||
if (blob === undefined) {
|
||||
throw new ConnectorError(`${PROVIDER}: upload returned no blob reference`, PROVIDER);
|
||||
}
|
||||
images.push({ alt: stringValue(altText[assetId]) ?? "", image: blob });
|
||||
}
|
||||
return images;
|
||||
}
|
||||
|
||||
async function deleteRecord(connection: ConnectorConnection, remoteId: string): Promise<void> {
|
||||
const parsed = parseAtUri(uriFromRemoteId(remoteId));
|
||||
await api<Record<string, never>>(
|
||||
connection,
|
||||
"com.atproto.repo.deleteRecord",
|
||||
jsonPost({ repo: await didOf(connection), collection: parsed.collection, rkey: parsed.rkey }),
|
||||
);
|
||||
}
|
||||
|
||||
async function identityFor(connection: ConnectorConnection): Promise<NormalizedIdentity> {
|
||||
const did = await didOf(connection);
|
||||
const profile = await api<ProfileResponse>(
|
||||
connection,
|
||||
`app.bsky.actor.getProfile?actor=${encodeURIComponent(did)}`,
|
||||
);
|
||||
handles.set(connection.id, profile.handle);
|
||||
const identity: NormalizedIdentity = {
|
||||
provider: PROVIDER,
|
||||
remoteId: profile.did,
|
||||
handle: profile.handle,
|
||||
};
|
||||
if (profile.displayName !== undefined && profile.displayName !== "") {
|
||||
identity.displayName = profile.displayName;
|
||||
}
|
||||
if (profile.avatar !== undefined && profile.avatar !== "") identity.avatarUrl = profile.avatar;
|
||||
return identity;
|
||||
}
|
||||
|
||||
return {
|
||||
manifest: () => {
|
||||
assertValidManifest(manifest);
|
||||
return manifest;
|
||||
},
|
||||
|
||||
async authorize(input: BlueskyAuthorizationRequest): Promise<AuthorizationResult> {
|
||||
const fields = input.fields ?? {};
|
||||
const identifier = fields.identifier?.trim();
|
||||
// appPassword remains accepted for callers already using AT's common
|
||||
// terminology; appKey is the SDK-safe manifest field name.
|
||||
const appPassword = (fields.appKey ?? fields.appPassword)?.trim();
|
||||
if (identifier === undefined || identifier === "") {
|
||||
throw new AuthError(`${PROVIDER}: authorize needs fields.identifier`, PROVIDER, false);
|
||||
}
|
||||
if (appPassword === undefined || appPassword === "") {
|
||||
throw new AuthError(
|
||||
`${PROVIDER}: authorize needs fields.appKey (an app password from Bluesky Settings, never the account password)`,
|
||||
PROVIDER,
|
||||
false,
|
||||
);
|
||||
}
|
||||
const service = normalizeService(fields.service);
|
||||
const session = await publicJson<SessionResponse>(
|
||||
service,
|
||||
"com.atproto.server.createSession",
|
||||
jsonPost({ identifier, password: appPassword }),
|
||||
);
|
||||
const metadata = input.providerMetadata ?? {};
|
||||
const connectionId = stringValue(metadata.connectionId) ?? `${PROVIDER}:${input.workspaceId}:${session.did}`;
|
||||
const credentialId: CredentialRef = { kind: "credential-ref", id: connectionId };
|
||||
const connection: ConnectorConnection = {
|
||||
id: connectionId,
|
||||
workspaceId: input.workspaceId,
|
||||
provider: PROVIDER,
|
||||
channelId: session.did,
|
||||
credentialId,
|
||||
fields: { identifier, service },
|
||||
createdAt: new Date(),
|
||||
};
|
||||
await credentials.put(connection, SECRET.accessToken, session.accessJwt);
|
||||
await credentials.put(connection, SECRET.refreshToken, session.refreshJwt);
|
||||
await credentials.put(connection, SECRET.did, session.did);
|
||||
const identity = await identityFor(connection);
|
||||
return { connection, identity };
|
||||
},
|
||||
|
||||
async getIdentity(connection: ConnectorConnection): Promise<NormalizedIdentity> {
|
||||
return identityFor(connection);
|
||||
},
|
||||
|
||||
async publish(connection: ConnectorConnection, post: NormalizedPost): Promise<Publication> {
|
||||
const createdAt = new Date();
|
||||
const record: Record<string, unknown> = { text: post.text, createdAt: createdAt.toISOString() };
|
||||
const images = await imagesFor(connection, post);
|
||||
if (images.length > 0) {
|
||||
record.embed = { $type: "app.bsky.embed.images", images };
|
||||
}
|
||||
const response = await createRecord(connection, POST_COLLECTION, record);
|
||||
const draftId = stringValue(post.providerMetadata.draftId) ?? "direct";
|
||||
return toPublication(connection, response, draftId, createdAt);
|
||||
},
|
||||
|
||||
async uploadMedia(connection: ConnectorConnection, media: MediaAsset): Promise<RemoteMedia> {
|
||||
return uploadAsset(connection, media);
|
||||
},
|
||||
|
||||
async getFeed(
|
||||
connection: ConnectorConnection,
|
||||
request: { kind: "home" | "mentions" | "search"; query?: string; cursor?: string; limit: number },
|
||||
): Promise<Page<FeedItem>> {
|
||||
const limit = Math.max(1, Math.min(100, request.limit));
|
||||
const params = new URLSearchParams({ limit: String(limit) });
|
||||
if (request.cursor !== undefined) params.set("cursor", request.cursor);
|
||||
|
||||
if (request.kind === "home") {
|
||||
const data = await api<TimelineResponse>(
|
||||
connection,
|
||||
`app.bsky.feed.getTimeline?${params.toString()}`,
|
||||
);
|
||||
return pageOf(data.feed.map((entry) => toFeedItem(entry.post)), data.cursor);
|
||||
}
|
||||
if (request.kind === "search") {
|
||||
const query = request.query?.trim();
|
||||
if (query === undefined || query === "") {
|
||||
throw new ConnectorError(`${PROVIDER}: search requires a query`, PROVIDER);
|
||||
}
|
||||
params.set("q", query);
|
||||
const data = await api<SearchResponse>(
|
||||
connection,
|
||||
`app.bsky.feed.searchPosts?${params.toString()}`,
|
||||
);
|
||||
return pageOf(data.posts.map(toFeedItem), data.cursor);
|
||||
}
|
||||
|
||||
const data = await api<NotificationsResponse>(
|
||||
connection,
|
||||
`app.bsky.notification.listNotifications?${params.toString()}`,
|
||||
);
|
||||
const items = data.notifications
|
||||
.filter((notification) => notification.reason === "mention" || notification.reason === "reply")
|
||||
.map((notification) =>
|
||||
toFeedItem({
|
||||
...notification,
|
||||
embed: undefined,
|
||||
likeCount: 0,
|
||||
repostCount: 0,
|
||||
replyCount: 0,
|
||||
}),
|
||||
);
|
||||
return pageOf(items, data.cursor);
|
||||
},
|
||||
|
||||
async getComments(
|
||||
connection: ConnectorConnection,
|
||||
request: { onRemoteId: string; cursor?: string; limit: number },
|
||||
): Promise<Page<Comment>> {
|
||||
const target = requireReference(request.onRemoteId);
|
||||
const params = new URLSearchParams({ uri: target.uri, depth: "1", parentHeight: "0" });
|
||||
const data = await api<PostThreadResponse>(
|
||||
connection,
|
||||
`app.bsky.feed.getPostThread?${params.toString()}`,
|
||||
);
|
||||
const replies = data.thread.replies ?? [];
|
||||
const offset = request.cursor === undefined ? 0 : Number(request.cursor);
|
||||
if (!Number.isInteger(offset) || offset < 0) {
|
||||
throw new ConnectorError(`${PROVIDER}: invalid comments cursor`, PROVIDER);
|
||||
}
|
||||
const slice = replies.slice(offset, offset + request.limit);
|
||||
const items = slice.map((reply): Comment => ({
|
||||
id: reply.post.uri,
|
||||
provider: PROVIDER,
|
||||
remoteId: rememberReference(reply.post, reply.post.record.reply?.root),
|
||||
onRemoteId: request.onRemoteId,
|
||||
authorHandle: reply.post.author.handle,
|
||||
text: reply.post.record.text,
|
||||
createdAt: new Date(reply.post.record.createdAt),
|
||||
}));
|
||||
const next = offset + slice.length;
|
||||
return pageOf(items, next < replies.length ? String(next) : undefined);
|
||||
},
|
||||
|
||||
async reply(
|
||||
connection: ConnectorConnection,
|
||||
request: { onRemoteId: string; text: string },
|
||||
): Promise<Publication> {
|
||||
const parent = requireReference(request.onRemoteId);
|
||||
const root = replyRoots.get(request.onRemoteId) ?? parent;
|
||||
const createdAt = new Date();
|
||||
const response = await createRecord(connection, POST_COLLECTION, {
|
||||
text: request.text,
|
||||
createdAt: createdAt.toISOString(),
|
||||
reply: { root, parent },
|
||||
});
|
||||
rememberReference(response, root);
|
||||
return toPublication(connection, response, "reply", createdAt, {
|
||||
reply: { root, parent },
|
||||
});
|
||||
},
|
||||
|
||||
async amplify(
|
||||
connection: ConnectorConnection,
|
||||
request: {
|
||||
kind: "repost" | "quote" | "share";
|
||||
targetRemoteId: string;
|
||||
commentary?: string;
|
||||
quoteAssetIds?: string[];
|
||||
},
|
||||
): Promise<Publication> {
|
||||
const target = requireReference(request.targetRemoteId);
|
||||
const createdAt = new Date();
|
||||
if (request.kind === "repost") {
|
||||
const response = await createRecord(connection, REPOST_COLLECTION, {
|
||||
subject: target,
|
||||
createdAt: createdAt.toISOString(),
|
||||
});
|
||||
return toPublication(connection, response, "amplify:repost", createdAt, {
|
||||
amplifies: target,
|
||||
kind: "repost",
|
||||
});
|
||||
}
|
||||
if (request.kind === "quote") {
|
||||
if ((request.quoteAssetIds?.length ?? 0) > 0) {
|
||||
throw new CapabilityNotSupportedError(PROVIDER, "quote posts with images");
|
||||
}
|
||||
const response = await createRecord(connection, POST_COLLECTION, {
|
||||
text: request.commentary ?? "",
|
||||
createdAt: createdAt.toISOString(),
|
||||
embed: { $type: "app.bsky.embed.record", record: target },
|
||||
});
|
||||
return toPublication(connection, response, "amplify:quote", createdAt, {
|
||||
amplifies: target,
|
||||
kind: "quote",
|
||||
});
|
||||
}
|
||||
throw new CapabilityNotSupportedError(PROVIDER, "amplifyShare");
|
||||
},
|
||||
|
||||
async unamplify(connection: ConnectorConnection, remoteId: string): Promise<void> {
|
||||
await deleteRecord(connection, remoteId);
|
||||
},
|
||||
|
||||
async deletePublication(connection: ConnectorConnection, remoteId: string): Promise<void> {
|
||||
await deleteRecord(connection, remoteId);
|
||||
},
|
||||
};
|
||||
}
|
||||
1
packages/connector-bluesky/src/rate-policy.usage.ts
Normal file
1
packages/connector-bluesky/src/rate-policy.usage.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export const documentedRates = { requestsPerSecond: 1 };
|
||||
Loading…
Add table
Add a link
Reference in a new issue