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
351 lines
9.7 KiB
TypeScript
351 lines
9.7 KiB
TypeScript
/**
|
|
* Classifies Neynar cast embeds (image / video / audio / webpage / frame / quoted
|
|
* cast) and turns them into the runtime's `Media` attachments. Image embeds are
|
|
* captioned with the vision model (`IMAGE_DESCRIPTION`); other kinds get a derived
|
|
* title/description. Results are memoized per embed id in an instance cache. Used
|
|
* by `InteractionManager` to enrich inbound mentions/replies with media.
|
|
*/
|
|
import { type IAgentRuntime, type Media, ModelType, withStandaloneTrajectory } from "@elizaos/core";
|
|
import type { EmbedCast, EmbedUrl, Embed as NeynarEmbed } from "@neynar/nodejs-sdk/build/api";
|
|
|
|
export function isEmbedUrl(embed: EmbedCast | EmbedUrl): embed is EmbedUrl {
|
|
return "url" in embed;
|
|
}
|
|
|
|
export function isEmbedCast(embed: EmbedCast | EmbedUrl): embed is EmbedCast {
|
|
return "cast" in embed;
|
|
}
|
|
|
|
function getMediaTypeFromUrl(
|
|
url: string,
|
|
contentType?: string | null
|
|
): "image" | "video" | "audio" | "webpage" | "unknown" {
|
|
const lowerUrl = url.toLowerCase();
|
|
const lowerContentType = contentType?.toLowerCase() || "";
|
|
|
|
if (lowerContentType.startsWith("image/")) return "image";
|
|
if (lowerContentType.startsWith("video/")) return "video";
|
|
if (lowerContentType.startsWith("audio/")) return "audio";
|
|
|
|
const imageExtensions = [".jpg", ".jpeg", ".png", ".gif", ".webp", ".svg", ".bmp", ".ico"];
|
|
const videoExtensions = [".mp4", ".webm", ".mov", ".avi", ".mkv", ".m4v"];
|
|
const audioExtensions = [".mp3", ".wav", ".ogg", ".flac", ".aac", ".m4a"];
|
|
|
|
if (imageExtensions.some((ext) => lowerUrl.includes(ext))) return "image";
|
|
if (videoExtensions.some((ext) => lowerUrl.includes(ext))) return "video";
|
|
if (audioExtensions.some((ext) => lowerUrl.includes(ext))) return "audio";
|
|
|
|
return "webpage";
|
|
}
|
|
|
|
export interface ProcessedEmbed {
|
|
id: string;
|
|
url: string;
|
|
type: "image" | "video" | "audio" | "webpage" | "cast" | "frame" | "unknown";
|
|
title?: string;
|
|
description?: string;
|
|
text?: string;
|
|
source: string;
|
|
metadata?: {
|
|
width?: number;
|
|
height?: number;
|
|
duration?: number;
|
|
contentType?: string;
|
|
castHash?: string;
|
|
authorFid?: number;
|
|
authorUsername?: string;
|
|
};
|
|
}
|
|
|
|
export class EmbedManager {
|
|
private runtime: IAgentRuntime;
|
|
private embedCache: Map<string, ProcessedEmbed> = new Map();
|
|
|
|
constructor(runtime: IAgentRuntime) {
|
|
this.runtime = runtime;
|
|
}
|
|
|
|
async processEmbeds(embeds: NeynarEmbed[]): Promise<Media[]> {
|
|
if (embeds.length === 0) {
|
|
return [];
|
|
}
|
|
|
|
this.runtime.logger.info(
|
|
{ embedCount: embeds.length },
|
|
"[EmbedManager] Processing embeds from cast"
|
|
);
|
|
|
|
const processedMedia: Media[] = [];
|
|
|
|
for (const embed of embeds) {
|
|
try {
|
|
const processed = await this.processEmbed(embed);
|
|
if (processed) {
|
|
processedMedia.push(this.toMedia(processed));
|
|
}
|
|
} catch (error) {
|
|
this.runtime.logger.warn(
|
|
{ error: error instanceof Error ? error.message : String(error) },
|
|
"[EmbedManager] Failed to process embed"
|
|
);
|
|
}
|
|
}
|
|
|
|
this.runtime.logger.info(
|
|
{
|
|
processedCount: processedMedia.length,
|
|
types: processedMedia.map((m) => m.source),
|
|
},
|
|
"[EmbedManager] Finished processing embeds"
|
|
);
|
|
|
|
return processedMedia;
|
|
}
|
|
|
|
async processEmbed(embed: NeynarEmbed): Promise<ProcessedEmbed | null> {
|
|
if (isEmbedUrl(embed)) {
|
|
return this.processUrlEmbed(embed);
|
|
} else if (isEmbedCast(embed)) {
|
|
return this.processCastEmbed(embed);
|
|
}
|
|
|
|
this.runtime.logger.debug("[EmbedManager] Unknown embed type");
|
|
return null;
|
|
}
|
|
|
|
private async processUrlEmbed(embed: EmbedUrl): Promise<ProcessedEmbed> {
|
|
const { url, metadata } = embed;
|
|
const embedId = `embed-${this.hashUrl(url)}`;
|
|
|
|
const cached = this.embedCache.get(embedId);
|
|
if (cached) {
|
|
return cached;
|
|
}
|
|
|
|
const contentType = metadata?.content_type;
|
|
const mediaType = getMediaTypeFromUrl(url, contentType);
|
|
|
|
if (metadata?.frame) {
|
|
return this.processFrameEmbed(embed, embedId);
|
|
}
|
|
|
|
let processed: ProcessedEmbed;
|
|
|
|
switch (mediaType) {
|
|
case "image":
|
|
processed = await this.processImageEmbed(embed, embedId);
|
|
break;
|
|
case "video":
|
|
processed = await this.processVideoEmbed(embed, embedId);
|
|
break;
|
|
case "audio":
|
|
processed = await this.processAudioEmbed(embed, embedId);
|
|
break;
|
|
default:
|
|
processed = await this.processWebpageEmbed(embed, embedId);
|
|
}
|
|
|
|
this.embedCache.set(embedId, processed);
|
|
return processed;
|
|
}
|
|
|
|
private async processImageEmbed(embed: EmbedUrl, embedId: string): Promise<ProcessedEmbed> {
|
|
const { url, metadata } = embed;
|
|
|
|
let description = "An image attachment";
|
|
let title = "Image";
|
|
|
|
try {
|
|
const result = await withStandaloneTrajectory(
|
|
this.runtime,
|
|
{ source: "farcaster-embed" },
|
|
async () =>
|
|
this.runtime.useModel(ModelType.IMAGE_DESCRIPTION, {
|
|
prompt:
|
|
"Analyze this image and provide a concise title and description. Focus on the main subject and any notable details.",
|
|
imageUrl: url,
|
|
})
|
|
);
|
|
|
|
if (result && typeof result === "object") {
|
|
const typedResult = result as { title?: string; description?: string };
|
|
description = typedResult.description || description;
|
|
title = typedResult.title || title;
|
|
} else if (typeof result === "string") {
|
|
description = result;
|
|
}
|
|
|
|
this.runtime.logger.info(
|
|
{
|
|
url: `${url.substring(0, 60)}...`,
|
|
descriptionLength: description.length,
|
|
title,
|
|
},
|
|
"[EmbedManager] Processed image with vision model"
|
|
);
|
|
} catch (error) {
|
|
this.runtime.logger.warn(
|
|
{ url, error: error instanceof Error ? error.message : String(error) },
|
|
"[EmbedManager] Failed to describe image, using fallback"
|
|
);
|
|
}
|
|
|
|
return {
|
|
id: embedId,
|
|
url,
|
|
type: "image",
|
|
title,
|
|
description,
|
|
text: description,
|
|
source: "Farcaster",
|
|
metadata: {
|
|
width: metadata?.image?.width_px,
|
|
height: metadata?.image?.height_px,
|
|
contentType: metadata?.content_type || "image/*",
|
|
},
|
|
};
|
|
}
|
|
|
|
private async processVideoEmbed(embed: EmbedUrl, embedId: string): Promise<ProcessedEmbed> {
|
|
const { url, metadata } = embed;
|
|
|
|
const description = metadata?.video?.duration_s
|
|
? `Video (${Math.round(metadata.video.duration_s)}s)`
|
|
: "Video attachment";
|
|
|
|
return {
|
|
id: embedId,
|
|
url,
|
|
type: "video",
|
|
title: "Video",
|
|
description,
|
|
text: description,
|
|
source: "Farcaster",
|
|
metadata: {
|
|
duration: metadata?.video?.duration_s,
|
|
contentType: metadata?.content_type || "video/*",
|
|
},
|
|
};
|
|
}
|
|
|
|
private async processAudioEmbed(embed: EmbedUrl, embedId: string): Promise<ProcessedEmbed> {
|
|
const { url, metadata } = embed;
|
|
|
|
const description = "Audio attachment";
|
|
|
|
return {
|
|
id: embedId,
|
|
url,
|
|
type: "audio",
|
|
title: "Audio",
|
|
description,
|
|
text: description,
|
|
source: "Farcaster",
|
|
metadata: {
|
|
contentType: metadata?.content_type || "audio/*",
|
|
},
|
|
};
|
|
}
|
|
|
|
private async processWebpageEmbed(embed: EmbedUrl, embedId: string): Promise<ProcessedEmbed> {
|
|
const { url, metadata } = embed;
|
|
const html = metadata?.html;
|
|
|
|
const title = html?.ogTitle || html?.ogSiteName || "Web Page";
|
|
const hostnameMatch = url.match(/^(?:https?:\/\/)?([^/?#]+)/);
|
|
const hostname = hostnameMatch ? hostnameMatch[1] : url;
|
|
const description = html?.ogDescription || `Link to ${hostname}`;
|
|
|
|
return {
|
|
id: embedId,
|
|
url,
|
|
type: "webpage",
|
|
title,
|
|
description,
|
|
text: `${title}: ${description}`,
|
|
source: "Web",
|
|
metadata: {
|
|
contentType: "text/html",
|
|
},
|
|
};
|
|
}
|
|
|
|
private async processFrameEmbed(embed: EmbedUrl, embedId: string): Promise<ProcessedEmbed> {
|
|
const { url, metadata } = embed;
|
|
const frame = metadata?.frame;
|
|
|
|
const title = frame?.title || "Farcaster Frame";
|
|
const description = `Interactive Frame: ${title}`;
|
|
|
|
return {
|
|
id: embedId,
|
|
url,
|
|
type: "frame",
|
|
title,
|
|
description,
|
|
text: description,
|
|
source: "Frame",
|
|
metadata: {
|
|
contentType: "application/x-farcaster-frame",
|
|
},
|
|
};
|
|
}
|
|
|
|
private async processCastEmbed(embed: EmbedCast): Promise<ProcessedEmbed> {
|
|
const cast = embed.cast;
|
|
const embedId = `cast-${cast.hash}`;
|
|
|
|
const cached = this.embedCache.get(embedId);
|
|
if (cached) {
|
|
return cached;
|
|
}
|
|
|
|
const authorUsername = cast.author.username || "unknown";
|
|
const title = `Quoted cast from @${authorUsername}`;
|
|
const description = cast.text || "";
|
|
|
|
const processed: ProcessedEmbed = {
|
|
id: embedId,
|
|
url: `https://warpcast.com/${authorUsername}/${cast.hash.slice(0, 10)}`,
|
|
type: "cast",
|
|
title,
|
|
description,
|
|
text: `[Quote from @${authorUsername}]: ${description}`,
|
|
source: "Farcaster",
|
|
metadata: {
|
|
castHash: cast.hash,
|
|
authorFid: cast.author.fid,
|
|
authorUsername,
|
|
},
|
|
};
|
|
|
|
this.embedCache.set(embedId, processed);
|
|
return processed;
|
|
}
|
|
|
|
private toMedia(embed: ProcessedEmbed): Media {
|
|
return {
|
|
id: embed.id,
|
|
url: embed.url,
|
|
title: embed.title || embed.type,
|
|
source: embed.source,
|
|
description: embed.description,
|
|
text: embed.text,
|
|
};
|
|
}
|
|
|
|
private hashUrl(url: string): string {
|
|
let hash = 0;
|
|
for (let i = 0; i < url.length; i++) {
|
|
const char = url.charCodeAt(i);
|
|
hash = (hash << 5) - hash + char;
|
|
hash = hash & hash;
|
|
}
|
|
return Math.abs(hash).toString(36);
|
|
}
|
|
|
|
clearCache(): void {
|
|
this.embedCache.clear();
|
|
}
|
|
}
|