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
420 lines
12 KiB
TypeScript
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);
|
|
}
|