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

420 lines
12 KiB
TypeScript

/**
* DM connector implementation for one BlueSky account: backs the runtime's
* message-connector surface over `chat.bsky` conversations. Sends and fetches
* DMs through `BlueSkyClient`, maps AT Protocol messages into runtime `Memory`
* records (with deterministic `createUniqueUuid` room/entity ids), and resolves
* connector targets/rooms/user-context by fuzzy-matching handles, display names,
* and DIDs across the agent's conversations. Empty outbound text falls back to
* an LLM-generated reply via the DM prompt template. Registered by
* `BlueSkyService`; not an elizaOS `Service` subclass despite the name.
*/
import {
ChannelType,
type Content,
composePrompt,
createUniqueUuid,
type IAgentRuntime,
type Memory,
type MessageConnectorChatContext,
type MessageConnectorQueryContext,
type MessageConnectorTarget,
type MessageConnectorUserContext,
ModelType,
type TargetInfo,
} from "@elizaos/core";
import type { BlueSkyClient } from "../client";
import { generateDmTemplate } from "../prompts.js";
import type { BlueSkyConversation, BlueSkyMessage } from "../types";
import {
normalizeBlueSkyAccountId,
readBlueSkyAccountId,
} from "../utils/config";
const BLUESKY_CONNECTOR_CONTEXTS = ["social", "connectors"];
function normalizeBlueSkyQuery(value: string): string {
return value.trim().replace(/^@/, "").toLowerCase();
}
function scoreBlueSkyMatch(
query: string,
id: string,
labels: Array<string | null | undefined>,
): number {
if (!query) return 0.45;
if (id.toLowerCase() === query) return 1;
let bestScore = 0;
for (const label of labels) {
const normalized = label?.trim().replace(/^@/, "").toLowerCase();
if (!normalized) continue;
if (normalized === query) {
bestScore = Math.max(bestScore, 0.95);
} else if (normalized.startsWith(query)) {
bestScore = Math.max(bestScore, 0.85);
} else if (normalized.includes(query)) {
bestScore = Math.max(bestScore, 0.7);
}
}
return bestScore;
}
export class BlueSkyMessageService {
static serviceType = "IMessageService";
constructor(
private readonly client: BlueSkyClient,
private readonly runtime: IAgentRuntime,
public readonly accountId: string = "default",
) {}
getAccountId(): string {
return normalizeBlueSkyAccountId(this.accountId);
}
async getMessages(convoId: string, limit = 50): Promise<BlueSkyMessage[]> {
const response = await this.client.getMessages(convoId, limit);
return response.messages;
}
async sendMessage(convoId: string, text: string): Promise<BlueSkyMessage> {
const messageText = text.trim() || (await this.generateReply());
return this.client.sendMessage({ convoId, message: { text: messageText } });
}
async getConversations(limit = 50): Promise<BlueSkyConversation[]> {
const response = await this.client.getConversations(limit);
return response.conversations;
}
async handleSendMessage(
runtime: IAgentRuntime,
target: TargetInfo,
content: Content,
): Promise<void> {
const requestedAccountId = normalizeBlueSkyAccountId(
target.accountId ??
readBlueSkyAccountId(content, target) ??
this.getAccountId(),
);
if (requestedAccountId !== this.getAccountId()) {
throw new Error(
`BlueSky account '${requestedAccountId}' is not available in this service instance`,
);
}
const text = typeof content.text === "string" ? content.text.trim() : "";
if (!text) {
throw new Error("BlueSky DM connector requires non-empty text content.");
}
let convoId = target.channelId ?? target.threadId;
if (!convoId && target.roomId) {
const room = await runtime.getRoom(target.roomId);
convoId = room?.channelId;
}
if (!convoId) {
throw new Error("BlueSky DM connector requires a conversation target.");
}
await this.sendMessage(convoId, text);
}
async fetchConnectorMessages(
context: MessageConnectorQueryContext,
params: {
target?: TargetInfo;
limit?: number;
before?: string;
after?: string;
} = {},
): Promise<Memory[]> {
const requestedAccountId = normalizeBlueSkyAccountId(
context.accountId ?? context.metadata?.accountId ?? this.getAccountId(),
);
if (requestedAccountId !== this.getAccountId()) {
throw new Error(
`BlueSky account '${requestedAccountId}' is not available in this service instance`,
);
}
const target = params.target ?? context.target;
let convoId = target?.channelId ?? target?.threadId;
if (!convoId && target?.roomId) {
const room = await context.runtime.getRoom(target.roomId);
convoId = room?.channelId;
}
if (convoId) {
const messages = await this.getMessages(
convoId,
clampLimit(params.limit, 25, 100),
);
return messages.map((message) =>
this.messageToMemory(context.runtime, message, convoId),
);
}
const conversations = await this.getConversations(
clampLimit(params.limit, 25, 50),
);
const memories: Memory[] = [];
for (const conversation of conversations) {
const messages = await this.getMessages(conversation.id, 1);
memories.push(
...messages.map((message) =>
this.messageToMemory(context.runtime, message, conversation.id),
),
);
}
return memories.sort((a, b) => (b.createdAt ?? 0) - (a.createdAt ?? 0));
}
async resolveConnectorTargets(
query: string,
_context: MessageConnectorQueryContext,
): Promise<MessageConnectorTarget[]> {
const normalizedQuery = normalizeBlueSkyQuery(query);
const conversations = await this.getConversations(50);
return conversations
.map((conversation) => {
const score = scoreBlueSkyMatch(normalizedQuery, conversation.id, [
...conversation.members.flatMap((member) => [
member.handle,
member.displayName,
member.did,
]),
]);
return score > 0
? this.buildConversationTarget(conversation, score)
: null;
})
.filter((target): target is MessageConnectorTarget => Boolean(target))
.slice(0, 25);
}
async listConnectorRooms(
_context: MessageConnectorQueryContext,
): Promise<MessageConnectorTarget[]> {
const conversations = await this.getConversations(50);
return conversations.map((conversation) =>
this.buildConversationTarget(conversation, 0.5),
);
}
async listRecentConnectorTargets(
context: MessageConnectorQueryContext,
): Promise<MessageConnectorTarget[]> {
const targets: MessageConnectorTarget[] = [];
const room =
context.roomId && typeof context.runtime.getRoom === "function"
? await context.runtime.getRoom(context.roomId)
: null;
const convoId = context.target?.channelId ?? room?.channelId;
if (convoId) {
targets.push({
target: {
source: "bluesky",
accountId: this.getAccountId(),
channelId: convoId,
} as TargetInfo,
label: `BlueSky conversation ${convoId}`,
kind: "thread",
score: 0.95,
contexts: [...BLUESKY_CONNECTOR_CONTEXTS],
metadata: { accountId: this.getAccountId(), blueskyConvoId: convoId },
});
}
targets.push(...(await this.listConnectorRooms(context)));
const seen = new Set<string>();
return targets
.filter((target) => {
const channelId = target.target.channelId;
if (!channelId || seen.has(channelId)) return false;
seen.add(channelId);
return true;
})
.slice(0, 25);
}
async getConnectorChatContext(
target: TargetInfo,
context: MessageConnectorQueryContext,
): Promise<MessageConnectorChatContext | null> {
let convoId = target.channelId ?? target.threadId;
if (!convoId && target.roomId) {
const room = await context.runtime.getRoom(target.roomId);
convoId = room?.channelId;
}
if (!convoId) return null;
const messages = await this.getMessages(convoId, 25);
return {
target: {
source: "bluesky",
accountId: this.getAccountId(),
channelId: convoId,
} as TargetInfo,
label: `BlueSky conversation ${convoId}`,
recentMessages: messages.map((message) => ({
name: message.sender.did,
text: message.text ?? "",
timestamp: Date.parse(message.sentAt),
metadata: {
accountId: this.getAccountId(),
blueskyMessageId: message.id,
blueskySenderDid: message.sender.did,
},
})),
metadata: { accountId: this.getAccountId(), blueskyConvoId: convoId },
};
}
async getConnectorUserContext(
entityId: string,
_context: MessageConnectorQueryContext,
): Promise<MessageConnectorUserContext | null> {
const normalizedEntity = entityId.trim().replace(/^@/, "");
if (!normalizedEntity) return null;
const conversations = await this.getConversations(50);
for (const conversation of conversations) {
const member = conversation.members.find(
(candidate) =>
candidate.did === entityId ||
candidate.handle === normalizedEntity ||
candidate.displayName === entityId,
);
if (!member) continue;
return {
entityId,
label: member.displayName || member.handle || member.did,
aliases: [member.handle, member.displayName, member.did].filter(
(value): value is string => Boolean(value),
),
handles: {
bluesky: member.handle ?? member.did,
},
metadata: {
accountId: this.getAccountId(),
blueskyDid: member.did,
blueskyHandle: member.handle,
avatar: member.avatar,
},
};
}
return null;
}
private buildConversationTarget(
conversation: BlueSkyConversation,
score: number,
): MessageConnectorTarget {
const sessionDid = this.client.getSession()?.did;
const otherMembers = conversation.members.filter(
(member) => member.did !== sessionDid,
);
const label =
otherMembers
.map((member) => member.displayName || member.handle || member.did)
.filter(Boolean)
.join(", ") || `BlueSky conversation ${conversation.id}`;
return {
target: {
source: "bluesky",
accountId: this.getAccountId(),
channelId: conversation.id,
} as TargetInfo,
label,
kind: "thread",
description: "BlueSky direct message conversation",
score,
contexts: [...BLUESKY_CONNECTOR_CONTEXTS],
metadata: {
accountId: this.getAccountId(),
blueskyConvoId: conversation.id,
unreadCount: conversation.unreadCount,
muted: conversation.muted,
members: conversation.members.map((member) => ({
did: member.did,
handle: member.handle,
displayName: member.displayName,
})),
},
};
}
private messageToMemory(
runtime: IAgentRuntime,
message: BlueSkyMessage,
convoId: string,
): Memory {
const senderDid = message.sender.did || "unknown";
const createdAt = Date.parse(message.sentAt) || Date.now();
const entityId =
senderDid === runtime.agentId
? runtime.agentId
: createUniqueUuid(runtime, `bluesky:user:${senderDid}`);
const roomId = createUniqueUuid(runtime, `bluesky:dm:${convoId}`);
return {
id: createUniqueUuid(runtime, `bluesky:dm:${message.id}`),
agentId: runtime.agentId,
entityId,
roomId,
createdAt,
content: {
text: message.text ?? "",
source: "bluesky",
channelType: ChannelType.DM,
},
metadata: {
type: "message",
source: "bluesky",
accountId: this.getAccountId(),
provider: "bluesky",
timestamp: createdAt,
fromBot: entityId === runtime.agentId,
messageIdFull: message.id,
chatType: ChannelType.DM,
sender: {
id: senderDid,
},
bluesky: {
accountId: this.getAccountId(),
messageId: message.id,
convoId,
rev: message.rev,
senderDid,
},
} as Memory["metadata"],
};
}
private async generateReply(): Promise<string> {
const prompt = composePrompt({
state: {},
template: generateDmTemplate,
});
const response = await this.runtime.useModel(ModelType.TEXT_SMALL, {
prompt,
maxTokens: 50,
});
return response as string;
}
}
function clampLimit(
value: number | undefined,
fallback: number,
max: number,
): number {
if (!Number.isFinite(value)) return fallback;
return Math.min(Math.max(1, Math.floor(value as number)), max);
}