426e9eeabd
Voice Workbench / headless workbench (mocked backends) (push) Has been cancelled
Voice Workbench / real acoustic lane (nightly, provisioned only) (push) Has been cancelled
ci / test (push) Has been cancelled
ci / lint-and-format (push) Has been cancelled
ci / build (push) Has been cancelled
ci / dev-startup (push) Has been cancelled
gitleaks / gitleaks (push) Has been cancelled
Markdown Links / Relative Markdown Links (push) Has been cancelled
Quality (Extended) / Homepage Build (PR smoke) (push) Has been cancelled
Quality (Extended) / Comment-only diff guard (push) Has been cancelled
Quality (Extended) / Format + Type Safety Ratchet (push) Has been cancelled
Quality (Extended) / Develop Gate (secret scan + UI determinism) (push) Has been cancelled
Quality (Extended) / Develop Gate (lint) (push) Has been cancelled
Chat shell gestures / Chat shell gesture + parity e2e (push) Has been cancelled
Cloud Gateway Discord / Test (push) Has been cancelled
Benchmark Bridge Tests / benchmark (bunx @biomejs/biome check packages/lifeops-bench/src, benchmark-lint) (push) Has been cancelled
Benchmark Bridge Tests / benchmark (bunx vitest run --config packages/lifeops-bench/vitest.config.ts --root packages/lifeops-bench --passWithNoTests, benchmark-tests) (push) Has been cancelled
Build Agent Image / build-and-push (push) Has been cancelled
Dev Smoke / bun run dev onboarding chat (push) Has been cancelled
Dev Smoke / Vite HMR dependency-level smoke (push) Has been cancelled
Electrobun Submodule Guard / electrobun gitlink is fetchable (push) Has been cancelled
Publish @elizaos/example-code / check_npm (push) Has been cancelled
Publish @elizaos/example-code / publish_npm (push) Has been cancelled
Publish @elizaos/plugin-elizacloud / verify_version (push) Has been cancelled
Publish @elizaos/plugin-elizacloud / publish_npm (push) Has been cancelled
Sandbox Live Smoke / Sandbox live smoke (push) Has been cancelled
Snap Build & Test / Build Snap (amd64) (push) Has been cancelled
Snap Build & Test / Build Snap (arm64) (push) Has been cancelled
Test Packaging / elizaos CLI global-install smoke (node + bun) (push) Has been cancelled
Cloud Gateway Webhook / Test (push) Has been cancelled
Cloud Tests / lint-and-types (push) Has been cancelled
Cloud Tests / unit-tests (push) Has been cancelled
Cloud Tests / integration-tests (push) Has been cancelled
Cloud Tests / e2e-tests (push) Has been cancelled
CodeQL Advanced / Analyze (javascript-typescript) (push) Has been cancelled
Deploy Apps Worker (Product 2) / Determine environment (push) Has been cancelled
Deploy Apps Worker (Product 2) / Deploy apps worker to apps-control host (${{ needs.determine-env.outputs.environment }}) (push) Has been cancelled
Deploy Eliza Provisioning Worker / Determine environment (push) Has been cancelled
Deploy Eliza Provisioning Worker / Deploy worker to Hetzner host (${{ needs.determine-env.outputs.environment }} @ ${{ needs.determine-env.outputs.deployment_sha }}) (push) Has been cancelled
Dev Smoke / Classify changed paths (push) Has been cancelled
supply-chain / sbom (push) Has been cancelled
supply-chain / vulnerability-scan (push) Has been cancelled
Build, Push & Deploy to Phala Cloud / build-and-push (push) Has been cancelled
Test Packaging / Validate Packaging Configs (push) Has been cancelled
Test Packaging / Build & Test PyPI Package (push) Has been cancelled
Test Packaging / PyPI on Python ${{ matrix.python }} (push) Has been cancelled
Test Packaging / Pack & Test JS Tarballs (push) Has been cancelled
UI Fixture E2E / ui-fixture-e2e (push) Has been cancelled
UI Fixture E2E / fixture-e2e (push) Has been cancelled
UI Story Gate / story-gate (push) Has been cancelled
vault-ci / test (macos-latest) (push) Has been cancelled
vault-ci / test (ubuntu-latest) (push) Has been cancelled
vault-ci / test (windows-latest) (push) Has been cancelled
vault-ci / app-core wiring tests (push) Has been cancelled
verify-patches / verify patches/CHECKSUMS.sha256 (push) Has been cancelled
Voice Benchmark Smoke / voice-emotion fixture smoke (push) Has been cancelled
Voice Benchmark Smoke / voiceagentbench fixture smoke (push) Has been cancelled
Voice Benchmark Smoke / voicebench-quality unit smoke (push) Has been cancelled
Voice Benchmark Smoke / voicebench TypeScript unit (no audio) (push) Has been cancelled
Voice Benchmark Smoke / voice bench smoke summary (push) Has been cancelled
Windows CI / windows ([bun run --cwd packages/app-core test bun run --cwd packages/elizaos test bun run --cwd packages/cloud/shared test], app-and-cli) (push) Has been cancelled
Windows CI / windows ([bun run --cwd packages/scenario-runner test bun run --cwd packages/vault test bun run --cwd packages/security test bun run --cwd plugins/plugin-coding-tools test], framework-packages) (push) Has been cancelled
Windows CI / windows ([bun run --cwd plugins/plugin-elizacloud test bun run --cwd plugins/plugin-discord test bun run --cwd plugins/plugin-anthropic test bun run --cwd plugins/plugin-openai test bun run --cwd plugins/plugin-app-control test bun run --cwd plugins/pl… (push) Has been cancelled
Windows CI / windows ([node packages/scripts/run-turbo.mjs run build --filter=@elizaos/core --filter=@elizaos/shared --filter=@elizaos/agent --concurrency=4 node packages/scripts/run-bash-linux-only.mjs scripts/verify-riscv64-buildpaths.sh node packages/scripts/run… (push) Has been cancelled
Windows CI / windows ([node packages/scripts/run-turbo.mjs run typecheck --filter=@elizaos/core --filter=@elizaos/shared --filter=@elizaos/cloud-shared --concurrency=4 bun run --cwd packages/core test bun run --cwd packages/shared test], core-runtime, 75) (push) Has been cancelled
438 lines
11 KiB
TypeScript
438 lines
11 KiB
TypeScript
/**
|
|
* AT Protocol transport for one BlueSky handle: wraps `@atproto/api`'s
|
|
* `BskyAgent` and adapts its raw `app.bsky.*` / `chat.bsky.*` responses into the
|
|
* plugin's domain shapes (`BlueSkyPost`, `BlueSkyMessage`, `BlueSkyConversation`,
|
|
* `BlueSkyNotification`). One instance per authenticated account; the services
|
|
* and the agent manager call through here for every network operation — posting,
|
|
* timeline/search reads, DM convo listing/sending, notifications, likes, reposts.
|
|
*
|
|
* DM operations go through the dedicated chat service DID via the
|
|
* `atproto-proxy` header. When `dryRun` is set, writes (post/delete/like/repost/
|
|
* sendMessage) are logged and return synthetic objects instead of hitting the
|
|
* network. Profiles are cached in a bounded LRU.
|
|
*/
|
|
import { type AppBskyFeedDefs, BskyAgent, RichText } from "@atproto/api";
|
|
import { logger } from "@elizaos/core";
|
|
import { LRUCache } from "lru-cache";
|
|
import type {
|
|
ATProtocolPostRecord,
|
|
ATProtocolProfileViewExtended,
|
|
BlueSkyConversation,
|
|
BlueSkyMessage,
|
|
BlueSkyNotification,
|
|
BlueSkyPost,
|
|
BlueSkyProfile,
|
|
BlueSkySession,
|
|
CreatePostRequest,
|
|
PostEmbed,
|
|
PostFacet,
|
|
SendMessageRequest,
|
|
TimelineRequest,
|
|
TimelineResponse,
|
|
} from "./types";
|
|
import {
|
|
BLUESKY_CHAT_SERVICE_DID,
|
|
BlueSkyError,
|
|
CACHE_SIZE,
|
|
CACHE_TTL,
|
|
} from "./types";
|
|
|
|
function isPostView(
|
|
item:
|
|
| AppBskyFeedDefs.PostView
|
|
| AppBskyFeedDefs.NotFoundPost
|
|
| AppBskyFeedDefs.BlockedPost
|
|
| { $type: string; [k: string]: unknown },
|
|
): item is AppBskyFeedDefs.PostView {
|
|
return (
|
|
typeof item === "object" &&
|
|
item !== null &&
|
|
"uri" in item &&
|
|
"cid" in item &&
|
|
"author" in item &&
|
|
"record" in item &&
|
|
"indexedAt" in item &&
|
|
typeof (item as AppBskyFeedDefs.PostView).uri === "string" &&
|
|
typeof (item as AppBskyFeedDefs.PostView).cid === "string"
|
|
);
|
|
}
|
|
|
|
function isReplyWithPostViews(
|
|
reply: AppBskyFeedDefs.ReplyRef | null | undefined,
|
|
): reply is AppBskyFeedDefs.ReplyRef & {
|
|
root: AppBskyFeedDefs.PostView;
|
|
parent: AppBskyFeedDefs.PostView;
|
|
} {
|
|
return (
|
|
typeof reply === "object" &&
|
|
reply !== null &&
|
|
"root" in reply &&
|
|
"parent" in reply &&
|
|
isPostView(reply.root) &&
|
|
isPostView(reply.parent)
|
|
);
|
|
}
|
|
|
|
function adaptPostView(postView: AppBskyFeedDefs.PostView): BlueSkyPost {
|
|
const author = postView.author as ATProtocolProfileViewExtended;
|
|
const record = postView.record as ATProtocolPostRecord;
|
|
|
|
return {
|
|
uri: postView.uri,
|
|
cid: postView.cid,
|
|
author: {
|
|
did: author.did,
|
|
handle: author.handle,
|
|
displayName: author.displayName,
|
|
description: author.description,
|
|
avatar: author.avatar,
|
|
banner: author.banner,
|
|
followersCount: author.followersCount,
|
|
followsCount: author.followsCount,
|
|
postsCount: author.postsCount,
|
|
indexedAt: author.indexedAt,
|
|
createdAt: author.createdAt,
|
|
},
|
|
record: {
|
|
$type: record.$type,
|
|
text: record.text,
|
|
facets: record.facets as PostFacet[] | undefined,
|
|
embed: record.embed as PostEmbed | undefined,
|
|
createdAt: record.createdAt,
|
|
},
|
|
embed: postView.embed as PostEmbed | undefined,
|
|
replyCount: postView.replyCount,
|
|
repostCount: postView.repostCount,
|
|
likeCount: postView.likeCount,
|
|
quoteCount: postView.quoteCount,
|
|
indexedAt: postView.indexedAt,
|
|
};
|
|
}
|
|
|
|
export interface BlueSkyClientConfig {
|
|
service: string;
|
|
handle: string;
|
|
password: string;
|
|
dryRun: boolean;
|
|
}
|
|
|
|
export class BlueSkyClient {
|
|
private readonly agent: BskyAgent;
|
|
private session: BlueSkySession | null = null;
|
|
private readonly profileCache: LRUCache<string, BlueSkyProfile>;
|
|
|
|
constructor(private readonly config: BlueSkyClientConfig) {
|
|
this.agent = new BskyAgent({ service: config.service });
|
|
this.profileCache = new LRUCache({
|
|
max: CACHE_SIZE.PROFILE,
|
|
ttl: CACHE_TTL.PROFILE,
|
|
});
|
|
}
|
|
|
|
async authenticate(): Promise<BlueSkySession> {
|
|
const response = await this.agent.login({
|
|
identifier: this.config.handle,
|
|
password: this.config.password,
|
|
});
|
|
|
|
if (!response.success) {
|
|
throw new BlueSkyError("Authentication failed", "AUTH_FAILED");
|
|
}
|
|
|
|
this.session = {
|
|
did: response.data.did,
|
|
handle: response.data.handle,
|
|
email: response.data.email,
|
|
accessJwt: response.data.accessJwt,
|
|
refreshJwt: response.data.refreshJwt,
|
|
};
|
|
|
|
logger.info(`Authenticated with BlueSky: ${this.session.handle}`);
|
|
return this.session;
|
|
}
|
|
|
|
getSession(): BlueSkySession | null {
|
|
return this.session;
|
|
}
|
|
|
|
async getProfile(handle: string): Promise<BlueSkyProfile> {
|
|
const cached = this.profileCache.get(handle);
|
|
if (cached) return cached;
|
|
|
|
const response = await this.agent.getProfile({ actor: handle });
|
|
const profile: BlueSkyProfile = {
|
|
did: response.data.did,
|
|
handle: response.data.handle,
|
|
displayName: response.data.displayName,
|
|
description: response.data.description,
|
|
avatar: response.data.avatar,
|
|
banner: response.data.banner,
|
|
followersCount: response.data.followersCount,
|
|
followsCount: response.data.followsCount,
|
|
postsCount: response.data.postsCount,
|
|
indexedAt: response.data.indexedAt,
|
|
createdAt: response.data.createdAt,
|
|
};
|
|
|
|
this.profileCache.set(handle, profile);
|
|
return profile;
|
|
}
|
|
|
|
async getTimeline(params: TimelineRequest = {}): Promise<TimelineResponse> {
|
|
const response = await this.agent.getTimeline({
|
|
algorithm: params.algorithm,
|
|
limit: params.limit ?? 50,
|
|
cursor: params.cursor,
|
|
});
|
|
|
|
return {
|
|
cursor: response.data.cursor,
|
|
feed: response.data.feed.map((item) => {
|
|
const reply = isReplyWithPostViews(item.reply)
|
|
? {
|
|
root: adaptPostView(item.reply.root),
|
|
parent: adaptPostView(item.reply.parent),
|
|
}
|
|
: undefined;
|
|
|
|
return {
|
|
post: adaptPostView(item.post),
|
|
reply,
|
|
reason: item.reason as Record<
|
|
string,
|
|
string | number | boolean | object | null | undefined
|
|
>,
|
|
};
|
|
}),
|
|
};
|
|
}
|
|
|
|
async searchPosts(params: {
|
|
query: string;
|
|
limit?: number;
|
|
cursor?: string;
|
|
}): Promise<{ posts: BlueSkyPost[]; cursor?: string }> {
|
|
const api = this.agent as {
|
|
app: {
|
|
bsky: {
|
|
feed: {
|
|
searchPosts: (params: {
|
|
q: string;
|
|
limit?: number;
|
|
cursor?: string;
|
|
}) => Promise<{
|
|
data: {
|
|
posts: AppBskyFeedDefs.PostView[];
|
|
cursor?: string;
|
|
};
|
|
}>;
|
|
};
|
|
};
|
|
};
|
|
};
|
|
const response = await api.app.bsky.feed.searchPosts({
|
|
q: params.query,
|
|
limit: params.limit ?? 25,
|
|
cursor: params.cursor,
|
|
});
|
|
|
|
return {
|
|
posts: response.data.posts.map(adaptPostView),
|
|
cursor: response.data.cursor,
|
|
};
|
|
}
|
|
|
|
async sendPost(request: CreatePostRequest): Promise<BlueSkyPost> {
|
|
if (this.config.dryRun) {
|
|
logger.info(
|
|
`Dry run: would create post with text: ${request.content.text}`,
|
|
);
|
|
return this.createDryRunPost(request.content.text);
|
|
}
|
|
|
|
const rt = new RichText({ text: request.content.text });
|
|
await rt.detectFacets(this.agent);
|
|
|
|
const record: Record<
|
|
string,
|
|
| string
|
|
| PostFacet[]
|
|
| PostEmbed
|
|
| {
|
|
root: { uri: string; cid: string };
|
|
parent: { uri: string; cid: string };
|
|
}
|
|
> = {
|
|
$type: "app.bsky.feed.post",
|
|
text: rt.text,
|
|
facets: rt.facets as PostFacet[],
|
|
createdAt: new Date().toISOString(),
|
|
};
|
|
|
|
if (request.replyTo) {
|
|
record.reply = { root: request.replyTo, parent: request.replyTo };
|
|
}
|
|
|
|
if (request.content.embed) {
|
|
record.embed = request.content.embed;
|
|
}
|
|
|
|
logger.info(
|
|
{
|
|
src: "plugin:bluesky",
|
|
op: "atproto:post",
|
|
textLength: rt.text.length,
|
|
hasReply: Boolean(request.replyTo),
|
|
hasEmbed: Boolean(request.content.embed),
|
|
},
|
|
"Publishing Bluesky post via atproto",
|
|
);
|
|
const response = await this.agent.post(record);
|
|
logger.info(
|
|
{
|
|
src: "plugin:bluesky",
|
|
op: "atproto:post",
|
|
uri: response.uri,
|
|
cid: response.cid,
|
|
},
|
|
"Bluesky post published",
|
|
);
|
|
const thread = await this.agent.getPostThread({
|
|
uri: response.uri,
|
|
depth: 0,
|
|
});
|
|
|
|
if (thread.data.thread.$type !== "app.bsky.feed.defs#threadViewPost") {
|
|
throw new BlueSkyError(
|
|
"Failed to retrieve created post",
|
|
"POST_CREATE_FAILED",
|
|
);
|
|
}
|
|
|
|
const threadViewPost = thread.data.thread as AppBskyFeedDefs.ThreadViewPost;
|
|
return adaptPostView(threadViewPost.post);
|
|
}
|
|
|
|
async deletePost(uri: string): Promise<void> {
|
|
if (this.config.dryRun) {
|
|
logger.info(`Dry run: would delete post: ${uri}`);
|
|
return;
|
|
}
|
|
await this.agent.deletePost(uri);
|
|
}
|
|
|
|
async likePost(uri: string, cid: string): Promise<void> {
|
|
if (this.config.dryRun) {
|
|
logger.info(`Dry run: would like post: ${uri}`);
|
|
return;
|
|
}
|
|
await this.agent.like(uri, cid);
|
|
}
|
|
|
|
async repost(uri: string, cid: string): Promise<void> {
|
|
if (this.config.dryRun) {
|
|
logger.info({ uri }, "Dry run: would repost");
|
|
return;
|
|
}
|
|
await this.agent.repost(uri, cid);
|
|
}
|
|
|
|
async getNotifications(
|
|
limit = 50,
|
|
cursor?: string,
|
|
): Promise<{
|
|
notifications: BlueSkyNotification[];
|
|
cursor?: string;
|
|
}> {
|
|
const response = await this.agent.listNotifications({ limit, cursor });
|
|
return {
|
|
notifications: response.data.notifications as BlueSkyNotification[],
|
|
cursor: response.data.cursor,
|
|
};
|
|
}
|
|
|
|
async updateSeenNotifications(): Promise<void> {
|
|
await this.agent.updateSeenNotifications();
|
|
}
|
|
|
|
async getConversations(
|
|
limit = 50,
|
|
cursor?: string,
|
|
): Promise<{
|
|
conversations: BlueSkyConversation[];
|
|
cursor?: string;
|
|
}> {
|
|
const response = await this.agent.api.chat.bsky.convo.listConvos(
|
|
{ limit, cursor },
|
|
{ headers: { "atproto-proxy": BLUESKY_CHAT_SERVICE_DID } },
|
|
);
|
|
return {
|
|
conversations: response.data.convos as BlueSkyConversation[],
|
|
cursor: response.data.cursor,
|
|
};
|
|
}
|
|
|
|
async getMessages(
|
|
convoId: string,
|
|
limit = 50,
|
|
cursor?: string,
|
|
): Promise<{
|
|
messages: BlueSkyMessage[];
|
|
cursor?: string;
|
|
}> {
|
|
const response = await this.agent.api.chat.bsky.convo.getMessages(
|
|
{ convoId, limit, cursor },
|
|
{ headers: { "atproto-proxy": BLUESKY_CHAT_SERVICE_DID } },
|
|
);
|
|
return {
|
|
messages: response.data.messages as BlueSkyMessage[],
|
|
cursor: response.data.cursor,
|
|
};
|
|
}
|
|
|
|
async sendMessage(request: SendMessageRequest): Promise<BlueSkyMessage> {
|
|
if (this.config.dryRun) {
|
|
logger.info({ convoId: request.convoId }, "Dry run: would send message");
|
|
return this.createDryRunMessage(request.message.text ?? "");
|
|
}
|
|
|
|
const response = await this.agent.api.chat.bsky.convo.sendMessage(
|
|
{
|
|
convoId: request.convoId,
|
|
message: { text: request.message.text ?? "" },
|
|
},
|
|
{ headers: { "atproto-proxy": BLUESKY_CHAT_SERVICE_DID } },
|
|
);
|
|
return response.data as BlueSkyMessage;
|
|
}
|
|
|
|
async cleanup(): Promise<void> {
|
|
this.profileCache.clear();
|
|
this.session = null;
|
|
}
|
|
|
|
private createDryRunPost(text: string): BlueSkyPost {
|
|
const now = new Date().toISOString();
|
|
return {
|
|
uri: `dryrun://post/${Date.now()}`,
|
|
cid: `dry-run-cid-${Date.now()}`,
|
|
author: {
|
|
did: this.session?.did ?? "did:plc:dryrun",
|
|
handle: this.session?.handle ?? "dry.run",
|
|
},
|
|
record: { $type: "app.bsky.feed.post", text, createdAt: now },
|
|
indexedAt: now,
|
|
};
|
|
}
|
|
|
|
private createDryRunMessage(text: string): BlueSkyMessage {
|
|
return {
|
|
id: `dry-run-msg-${Date.now()}`,
|
|
rev: "1",
|
|
text,
|
|
sender: { did: this.session?.did ?? "did:plc:dryrun" },
|
|
sentAt: new Date().toISOString(),
|
|
};
|
|
}
|
|
}
|