Files
wehub-resource-sync 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
chore: import upstream snapshot with attribution
2026-07-13 12:43:05 +08:00

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(),
};
}
}