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
736 lines
21 KiB
TypeScript
736 lines
21 KiB
TypeScript
/**
|
|
* Downloads Discord message attachments and detects their media type. Exposes
|
|
* `AttachmentManager`, used when normalizing inbound messages into runtime
|
|
* Media.
|
|
*/
|
|
import fs from "node:fs";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
import {
|
|
ContentType,
|
|
type IAgentRuntime,
|
|
type Media,
|
|
ModelType,
|
|
type Service,
|
|
ServiceType,
|
|
} from "@elizaos/core";
|
|
import { type Attachment, Collection } from "discord.js";
|
|
import ffmpeg from "fluent-ffmpeg";
|
|
import { generateSummary } from "./utils";
|
|
|
|
const TEXT_ATTACHMENT_EXTENSIONS = new Set([
|
|
".cjs",
|
|
".conf",
|
|
".csv",
|
|
".env",
|
|
".ini",
|
|
".js",
|
|
".json",
|
|
".jsonl",
|
|
".jsx",
|
|
".log",
|
|
".md",
|
|
".mdx",
|
|
".mjs",
|
|
".sql",
|
|
".toml",
|
|
".ts",
|
|
".tsx",
|
|
".txt",
|
|
".xml",
|
|
".yaml",
|
|
".yml",
|
|
]);
|
|
|
|
const TEXT_ATTACHMENT_MIME_TYPES = new Set([
|
|
"application/javascript",
|
|
"application/json",
|
|
"application/ld+json",
|
|
"application/typescript",
|
|
"application/x-javascript",
|
|
"application/x-ndjson",
|
|
"application/x-yaml",
|
|
"application/xml",
|
|
"application/yaml",
|
|
]);
|
|
|
|
function normalizedMimeType(contentType: string | null | undefined): string {
|
|
return (contentType ?? "").split(";")[0]?.trim().toLowerCase() ?? "";
|
|
}
|
|
|
|
function attachmentExtension(attachment: Attachment): string {
|
|
return path.extname(attachment.name ?? "").toLowerCase();
|
|
}
|
|
|
|
function isReadableTextAttachment(attachment: Attachment): boolean {
|
|
const mimeType = normalizedMimeType(attachment.contentType);
|
|
if (
|
|
mimeType.startsWith("text/") ||
|
|
TEXT_ATTACHMENT_MIME_TYPES.has(mimeType)
|
|
) {
|
|
return true;
|
|
}
|
|
|
|
if (
|
|
!mimeType ||
|
|
mimeType === "application/octet-stream" ||
|
|
mimeType === "binary/octet-stream"
|
|
) {
|
|
return TEXT_ATTACHMENT_EXTENSIONS.has(attachmentExtension(attachment));
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
function isSafeRemoteAttachmentUrl(url: string): boolean {
|
|
try {
|
|
const parsed = new URL(url);
|
|
return parsed.protocol === "http:" || parsed.protocol === "https:";
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Class representing an Attachment Manager.
|
|
*/
|
|
export class AttachmentManager {
|
|
private attachmentCache: Map<string, Media> = new Map();
|
|
private runtime: IAgentRuntime;
|
|
|
|
/**
|
|
* Constructor for creating a new instance of the class.
|
|
*
|
|
* @param {IAgentRuntime} runtime The runtime object to be injected into the instance.
|
|
*/
|
|
constructor(runtime: IAgentRuntime) {
|
|
this.runtime = runtime;
|
|
}
|
|
|
|
private isImageDescriptionEnabled(): boolean {
|
|
const disabled = this.runtime.getSetting("DISABLE_IMAGE_DESCRIPTION");
|
|
if (
|
|
disabled === true ||
|
|
(typeof disabled === "string" &&
|
|
["1", "true", "yes", "on"].includes(disabled.trim().toLowerCase()))
|
|
) {
|
|
return false;
|
|
}
|
|
|
|
return (
|
|
typeof this.runtime.getModel(ModelType.IMAGE_DESCRIPTION) === "function"
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Processes attachments and returns an array of Media objects.
|
|
* @param {Collection<string, Attachment> | Attachment[]} attachments - The attachments to be processed
|
|
* @returns {Promise<Media[]>} - An array of processed Media objects
|
|
*/
|
|
async processAttachments(
|
|
attachments: Collection<string, Attachment> | Attachment[],
|
|
): Promise<Media[]> {
|
|
const processedAttachments: Media[] = [];
|
|
const attachmentCollection =
|
|
attachments instanceof Collection
|
|
? attachments
|
|
: new Collection(attachments.map((att) => [att.id, att]));
|
|
|
|
for (const [, attachment] of attachmentCollection) {
|
|
const media = await this.processAttachment(attachment);
|
|
if (media) {
|
|
processedAttachments.push(media);
|
|
}
|
|
}
|
|
|
|
return processedAttachments;
|
|
}
|
|
|
|
/**
|
|
* Processes the provided attachment to generate a media object.
|
|
* If the media for the attachment URL is already cached, it will return the cached media.
|
|
* Otherwise, it will determine the type of attachment (PDF, text, audio, video, image, generic)
|
|
* and call the corresponding processing method to generate the media object.
|
|
*
|
|
* @param attachment The attachment to process
|
|
* @returns A promise that resolves to a Media object representing the attachment, or null if the attachment could not be processed
|
|
*/
|
|
async processAttachment(attachment: Attachment): Promise<Media | null> {
|
|
const cached = this.attachmentCache.get(attachment.url);
|
|
if (cached) {
|
|
return cached;
|
|
}
|
|
|
|
let media: Media | null = null;
|
|
if (!isSafeRemoteAttachmentUrl(attachment.url)) {
|
|
this.runtime.logger.warn(
|
|
{
|
|
src: "plugin:discord",
|
|
agentId: this.runtime.agentId,
|
|
attachmentId: attachment.id,
|
|
url: attachment.url,
|
|
},
|
|
"Skipping attachment with non-remote URL",
|
|
);
|
|
media = await this.processGenericAttachment(attachment);
|
|
this.attachmentCache.set(attachment.url, media);
|
|
return media;
|
|
}
|
|
|
|
const mimeType = normalizedMimeType(attachment.contentType);
|
|
if (mimeType === "application/pdf") {
|
|
media = await this.processPdfAttachment(attachment);
|
|
} else if (isReadableTextAttachment(attachment)) {
|
|
media = await this.processPlaintextAttachment(attachment);
|
|
} else if (mimeType.startsWith("audio/") || mimeType === "video/mp4") {
|
|
media = await this.processAudioVideoAttachment(attachment);
|
|
} else if (mimeType.startsWith("image/")) {
|
|
media = await this.processImageAttachment(attachment);
|
|
} else if (mimeType.startsWith("video/")) {
|
|
media = await this.processVideoAttachment(attachment);
|
|
} else {
|
|
const videoService = this.runtime.getService(ServiceType.VIDEO) as
|
|
| ({ isVideoUrl?: (url: string) => boolean } & Service)
|
|
| null;
|
|
if (videoService?.isVideoUrl?.(attachment.url)) {
|
|
media = await this.processVideoAttachment(attachment);
|
|
} else {
|
|
media = await this.processGenericAttachment(attachment);
|
|
}
|
|
}
|
|
|
|
if (media) {
|
|
this.attachmentCache.set(attachment.url, media);
|
|
}
|
|
return media;
|
|
}
|
|
|
|
/**
|
|
* Asynchronously processes an audio or video attachment provided as input and returns a Media object.
|
|
* @param {Attachment} attachment - The attachment object containing information about the audio/video file.
|
|
* @returns {Promise<Media>} A Promise that resolves to a Media object representing the processed audio/video attachment.
|
|
*/
|
|
private async processAudioVideoAttachment(
|
|
attachment: Attachment,
|
|
): Promise<Media> {
|
|
try {
|
|
const response = await fetch(attachment.url);
|
|
const audioVideoArrayBuffer = await response.arrayBuffer();
|
|
|
|
let audioBuffer: Buffer;
|
|
let audioFileName: string;
|
|
let audioMimeType: string;
|
|
|
|
if (attachment.contentType?.startsWith("audio/")) {
|
|
audioBuffer = Buffer.from(audioVideoArrayBuffer);
|
|
audioFileName = attachment.name || "audio.mp3";
|
|
audioMimeType = attachment.contentType;
|
|
} else if (attachment.contentType?.startsWith("video/mp4")) {
|
|
audioBuffer = await this.extractAudioFromMP4(audioVideoArrayBuffer);
|
|
audioFileName = "extracted_audio.mp3";
|
|
audioMimeType = "audio/mpeg";
|
|
} else {
|
|
throw new Error("Unsupported audio/video format");
|
|
}
|
|
|
|
// Convert Buffer to File object for transcription API
|
|
const audioBlob = new Blob([new Uint8Array(audioBuffer)], {
|
|
type: audioMimeType,
|
|
});
|
|
const audioFile = new File([audioBlob], audioFileName, {
|
|
type: audioMimeType,
|
|
});
|
|
|
|
// Convert File to Buffer for transcription
|
|
const transcriptionBuffer = Buffer.from(await audioFile.arrayBuffer());
|
|
const transcription = await this.runtime.useModel(
|
|
ModelType.TRANSCRIPTION,
|
|
transcriptionBuffer,
|
|
);
|
|
|
|
// Assess transcription length before summarizing
|
|
const transcriptionLength = transcription?.length || 0;
|
|
this.runtime.logger.debug(
|
|
{
|
|
src: "plugin:discord",
|
|
agentId: this.runtime.agentId,
|
|
attachmentId: attachment.id,
|
|
contentType: attachment.contentType,
|
|
transcriptionLength,
|
|
},
|
|
"Assessing transcription length before summarization",
|
|
);
|
|
|
|
// Only summarize if transcription is meaningful (not empty and long enough)
|
|
let title: string | undefined;
|
|
let description: string | undefined;
|
|
|
|
if (!transcription || transcriptionLength === 0) {
|
|
this.runtime.logger.debug(
|
|
{
|
|
src: "plugin:discord",
|
|
agentId: this.runtime.agentId,
|
|
attachmentId: attachment.id,
|
|
},
|
|
"Transcription is empty, skipping summarization",
|
|
);
|
|
title = undefined;
|
|
description =
|
|
"User-uploaded audio/video attachment (no transcription available)";
|
|
} else if (transcriptionLength < 1000) {
|
|
// Short transcriptions don't benefit from summarization
|
|
this.runtime.logger.debug(
|
|
{
|
|
src: "plugin:discord",
|
|
agentId: this.runtime.agentId,
|
|
attachmentId: attachment.id,
|
|
transcriptionLength,
|
|
},
|
|
"Transcription is short, skipping summarization",
|
|
);
|
|
title = undefined;
|
|
description = transcription;
|
|
} else {
|
|
// Transcription is long enough to benefit from summarization
|
|
this.runtime.logger.debug(
|
|
{
|
|
src: "plugin:discord",
|
|
agentId: this.runtime.agentId,
|
|
attachmentId: attachment.id,
|
|
transcriptionLength,
|
|
},
|
|
"Summarizing transcription",
|
|
);
|
|
const summary = await generateSummary(this.runtime, transcription);
|
|
title = summary.title;
|
|
description = summary.description;
|
|
}
|
|
|
|
return {
|
|
id: attachment.id,
|
|
url: attachment.url,
|
|
title: title || "Audio/Video Attachment",
|
|
source: attachment.contentType?.startsWith("audio/")
|
|
? "Audio"
|
|
: "Video",
|
|
contentType: attachment.contentType?.startsWith("audio/")
|
|
? ContentType.AUDIO
|
|
: ContentType.VIDEO,
|
|
description:
|
|
description ||
|
|
"User-uploaded audio/video attachment which has been transcribed",
|
|
text: transcription || "",
|
|
};
|
|
} catch (error) {
|
|
this.runtime.logger.error(
|
|
{
|
|
src: "plugin:discord",
|
|
agentId: this.runtime.agentId,
|
|
attachmentId: attachment.id,
|
|
contentType: attachment.contentType,
|
|
error: error instanceof Error ? error.message : String(error),
|
|
},
|
|
"Error processing audio/video attachment",
|
|
);
|
|
|
|
return {
|
|
id: attachment.id,
|
|
url: attachment.url,
|
|
title: "Audio/Video Attachment",
|
|
source: attachment.contentType?.startsWith("audio/")
|
|
? "Audio"
|
|
: "Video",
|
|
contentType: attachment.contentType?.startsWith("audio/")
|
|
? ContentType.AUDIO
|
|
: ContentType.VIDEO,
|
|
description: "An audio/video attachment (transcription failed)",
|
|
text: "",
|
|
};
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Extracts the audio stream from the provided MP4 data and converts it to MP3 format.
|
|
*
|
|
* @param {ArrayBuffer} mp4Data - The MP4 data to extract audio from
|
|
* @returns {Promise<Buffer>} - A Promise that resolves with the converted audio data as a Buffer
|
|
*/
|
|
private async extractAudioFromMP4(mp4Data: ArrayBuffer): Promise<Buffer> {
|
|
// Use fluent-ffmpeg to extract the audio stream from the MP4 data
|
|
// and convert it to MP3 format
|
|
const tmpDir = os.tmpdir();
|
|
const timestamp = Date.now();
|
|
const tempMP4File = path.join(tmpDir, `discord_video_${timestamp}.mp4`);
|
|
const tempAudioFile = path.join(tmpDir, `discord_audio_${timestamp}.mp3`);
|
|
|
|
try {
|
|
// Write the MP4 data to a temporary file
|
|
fs.writeFileSync(tempMP4File, Buffer.from(mp4Data));
|
|
|
|
// Check if file has audio stream using ffprobe
|
|
await new Promise<void>((resolve, reject) => {
|
|
ffmpeg.ffprobe(tempMP4File, (err, metadata) => {
|
|
if (err) {
|
|
reject(err);
|
|
return;
|
|
}
|
|
|
|
if (!metadata.streams || !Array.isArray(metadata.streams)) {
|
|
reject(
|
|
new Error(
|
|
"File metadata does not contain valid streams information",
|
|
),
|
|
);
|
|
return;
|
|
}
|
|
|
|
const hasAudio = metadata.streams.some(
|
|
(stream) => stream.codec_type === "audio",
|
|
);
|
|
if (!hasAudio) {
|
|
reject(new Error("File does not contain any audio streams"));
|
|
return;
|
|
}
|
|
resolve();
|
|
});
|
|
});
|
|
|
|
this.runtime.logger.debug(
|
|
{
|
|
src: "plugin:discord",
|
|
agentId: this.runtime.agentId,
|
|
tempMP4File,
|
|
tempAudioFile,
|
|
},
|
|
"Extracting audio from MP4",
|
|
);
|
|
|
|
// Extract the audio stream and convert it to MP3
|
|
await new Promise<void>((resolve, reject) => {
|
|
ffmpeg(tempMP4File)
|
|
.noVideo() // Disable video output
|
|
.audioCodec("libmp3lame") // Set audio codec to MP3
|
|
.toFormat("mp3") // Explicitly set output format
|
|
.on("end", () => {
|
|
resolve();
|
|
})
|
|
.on("error", (err) => {
|
|
reject(err);
|
|
})
|
|
.output(tempAudioFile)
|
|
.run();
|
|
});
|
|
|
|
// Read the converted audio file and return it as a Buffer
|
|
const audioData = fs.readFileSync(tempAudioFile);
|
|
|
|
this.runtime.logger.debug(
|
|
{
|
|
src: "plugin:discord",
|
|
agentId: this.runtime.agentId,
|
|
audioDataSize: audioData.length,
|
|
},
|
|
"Successfully extracted audio from MP4",
|
|
);
|
|
|
|
return audioData;
|
|
} finally {
|
|
// Clean up the temporary files
|
|
try {
|
|
if (fs.existsSync(tempMP4File)) {
|
|
fs.unlinkSync(tempMP4File);
|
|
}
|
|
if (fs.existsSync(tempAudioFile)) {
|
|
fs.unlinkSync(tempAudioFile);
|
|
}
|
|
} catch (cleanupError) {
|
|
this.runtime.logger.warn(
|
|
{
|
|
src: "plugin:discord",
|
|
agentId: this.runtime.agentId,
|
|
error:
|
|
cleanupError instanceof Error
|
|
? cleanupError.message
|
|
: String(cleanupError),
|
|
},
|
|
"Failed to cleanup temp files",
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Processes a PDF attachment by fetching the PDF file from the specified URL,
|
|
* converting it to text, generating a summary, and returning a Media object
|
|
* with the extracted information.
|
|
* If an error occurs during processing, an error Media object is returned
|
|
* with an error message.
|
|
*
|
|
* @param {Attachment} attachment - The PDF attachment to process.
|
|
* @returns {Promise<Media>} A promise that resolves to a Media object representing
|
|
* the processed PDF attachment.
|
|
*/
|
|
private async processPdfAttachment(attachment: Attachment): Promise<Media> {
|
|
try {
|
|
const response = await fetch(attachment.url);
|
|
const pdfBuffer = await response.arrayBuffer();
|
|
const pdfService = this.runtime.getService(ServiceType.PDF) as
|
|
| ({ convertPdfToText: (buffer: Buffer) => Promise<string> } & Service)
|
|
| null;
|
|
if (!pdfService) {
|
|
throw new Error("PDF service not found");
|
|
}
|
|
const text = await pdfService.convertPdfToText(Buffer.from(pdfBuffer));
|
|
this.runtime.logger.debug(
|
|
{
|
|
src: "plugin:discord",
|
|
agentId: this.runtime.agentId,
|
|
attachmentId: attachment.id,
|
|
textLength: text?.length,
|
|
},
|
|
"Summarizing PDF content",
|
|
);
|
|
const { title, description } = await generateSummary(this.runtime, text);
|
|
|
|
return {
|
|
id: attachment.id,
|
|
url: attachment.url,
|
|
title: title || "PDF Attachment",
|
|
source: "PDF",
|
|
contentType: ContentType.DOCUMENT,
|
|
description: description || "A PDF document",
|
|
text,
|
|
};
|
|
} catch (error) {
|
|
this.runtime.logger.error(
|
|
{
|
|
src: "plugin:discord",
|
|
agentId: this.runtime.agentId,
|
|
attachmentId: attachment.id,
|
|
contentType: attachment.contentType,
|
|
error: error instanceof Error ? error.message : String(error),
|
|
},
|
|
"Error processing PDF attachment",
|
|
);
|
|
|
|
return {
|
|
id: attachment.id,
|
|
url: attachment.url,
|
|
title: "PDF Attachment (conversion failed)",
|
|
source: "PDF",
|
|
contentType: ContentType.DOCUMENT,
|
|
description: "A PDF document that could not be converted to text",
|
|
text: "",
|
|
};
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Processes a plaintext attachment by fetching its content, generating a summary, and returning a Media object.
|
|
* @param {Attachment} attachment - The attachment object to process.
|
|
* @returns {Promise<Media>} A promise that resolves to a Media object representing the processed plaintext attachment.
|
|
*/
|
|
private async processPlaintextAttachment(
|
|
attachment: Attachment,
|
|
): Promise<Media> {
|
|
try {
|
|
const response = await fetch(attachment.url);
|
|
const text = await response.text();
|
|
this.runtime.logger.debug(
|
|
{
|
|
src: "plugin:discord",
|
|
agentId: this.runtime.agentId,
|
|
attachmentId: attachment.id,
|
|
textLength: text?.length,
|
|
},
|
|
"Summarizing plaintext content",
|
|
);
|
|
const { title, description } = await generateSummary(this.runtime, text);
|
|
|
|
return {
|
|
id: attachment.id,
|
|
url: attachment.url,
|
|
title: title || "Plaintext Attachment",
|
|
source: "Plaintext",
|
|
contentType: ContentType.DOCUMENT,
|
|
description: description || "A plaintext document",
|
|
text,
|
|
};
|
|
} catch (error) {
|
|
this.runtime.logger.error(
|
|
{
|
|
src: "plugin:discord",
|
|
agentId: this.runtime.agentId,
|
|
attachmentId: attachment.id,
|
|
contentType: attachment.contentType,
|
|
error: error instanceof Error ? error.message : String(error),
|
|
},
|
|
"Error processing plaintext attachment",
|
|
);
|
|
|
|
return {
|
|
id: attachment.id,
|
|
url: attachment.url,
|
|
title: "Plaintext Attachment (retrieval failed)",
|
|
source: "Plaintext",
|
|
contentType: ContentType.DOCUMENT,
|
|
description: "A plaintext document that could not be retrieved",
|
|
text: "",
|
|
};
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Process the image attachment by fetching description and title using the IMAGE_DESCRIPTION model.
|
|
* If successful, returns a Media object populated with the details. If unsuccessful, creates a fallback
|
|
* Media object and logs the error.
|
|
*
|
|
* @param {Attachment} attachment - The attachment object containing the image details.
|
|
* @returns {Promise<Media>} A promise that resolves to a Media object.
|
|
*/
|
|
private async processImageAttachment(attachment: Attachment): Promise<Media> {
|
|
if (!this.isImageDescriptionEnabled()) {
|
|
this.runtime.logger.debug(
|
|
{
|
|
src: "plugin:discord",
|
|
agentId: this.runtime.agentId,
|
|
attachmentId: attachment.id,
|
|
contentType: attachment.contentType,
|
|
},
|
|
"Skipping image attachment description because IMAGE_DESCRIPTION is not available",
|
|
);
|
|
return this.createFallbackImageMedia(attachment);
|
|
}
|
|
|
|
try {
|
|
const { description, title } = await this.runtime.useModel(
|
|
ModelType.IMAGE_DESCRIPTION,
|
|
attachment.url,
|
|
);
|
|
return {
|
|
id: attachment.id,
|
|
url: attachment.url,
|
|
title: title || "Image Attachment",
|
|
source: "Image",
|
|
contentType: ContentType.IMAGE,
|
|
description: description || "An image attachment",
|
|
text: description || "",
|
|
};
|
|
} catch (error) {
|
|
this.runtime.logger.error(
|
|
{
|
|
src: "plugin:discord",
|
|
agentId: this.runtime.agentId,
|
|
attachmentId: attachment.id,
|
|
contentType: attachment.contentType,
|
|
error: error instanceof Error ? error.message : String(error),
|
|
},
|
|
"Error processing image attachment",
|
|
);
|
|
|
|
return this.createFallbackImageMedia(attachment);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Creates a fallback Media object for image attachments that could not be recognized.
|
|
*
|
|
* @param {Attachment} attachment - The attachment object containing image details.
|
|
* @returns {Media} - The fallback Media object with basic information about the image attachment.
|
|
*/
|
|
|
|
private createFallbackImageMedia(attachment: Attachment): Media {
|
|
return {
|
|
id: attachment.id,
|
|
url: attachment.url,
|
|
title: "Image Attachment",
|
|
source: "Image",
|
|
contentType: ContentType.IMAGE,
|
|
description: "An image attachment (recognition failed)",
|
|
text: "",
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Process a video attachment to extract video information.
|
|
* @param {Attachment} attachment - The attachment object containing video information.
|
|
* @returns {Promise<Media>} A promise that resolves to a Media object with video details.
|
|
* @throws {Error} If video service is not available.
|
|
*/
|
|
private async processVideoAttachment(attachment: Attachment): Promise<Media> {
|
|
const videoService = this.runtime.getService(ServiceType.VIDEO) as
|
|
| ({
|
|
isVideoUrl?: (url: string) => boolean;
|
|
processVideo?: (
|
|
url: string,
|
|
runtime: IAgentRuntime,
|
|
) => Promise<{
|
|
title: string;
|
|
description: string;
|
|
text: string;
|
|
}>;
|
|
} & Service)
|
|
| null;
|
|
|
|
if (!videoService) {
|
|
return {
|
|
id: attachment.id,
|
|
url: attachment.url,
|
|
title: "Video Attachment (Service Unavailable)",
|
|
source: "Video",
|
|
contentType: ContentType.VIDEO,
|
|
description:
|
|
"Could not process video attachment because the required service is not available.",
|
|
text: "",
|
|
};
|
|
}
|
|
|
|
if (
|
|
typeof videoService.isVideoUrl === "function" &&
|
|
typeof videoService.processVideo === "function" &&
|
|
videoService.isVideoUrl(attachment.url)
|
|
) {
|
|
const videoInfo = await videoService.processVideo(
|
|
attachment.url,
|
|
this.runtime,
|
|
);
|
|
return {
|
|
id: attachment.id,
|
|
url: attachment.url,
|
|
title: videoInfo.title,
|
|
source: "YouTube",
|
|
contentType: ContentType.VIDEO,
|
|
description: videoInfo.description,
|
|
text: videoInfo.text,
|
|
};
|
|
}
|
|
return {
|
|
id: attachment.id,
|
|
url: attachment.url,
|
|
title: "Video Attachment",
|
|
source: "Video",
|
|
contentType: ContentType.VIDEO,
|
|
description: "A video attachment",
|
|
text: "",
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Process a generic attachment and return a Media object with specified properties.
|
|
* @param {Attachment} attachment - The attachment object to process.
|
|
* @returns {Promise<Media>} A Promise that resolves to a Media object with specified properties.
|
|
*/
|
|
private async processGenericAttachment(
|
|
attachment: Attachment,
|
|
): Promise<Media> {
|
|
return {
|
|
id: attachment.id,
|
|
url: attachment.url,
|
|
title: "Generic Attachment",
|
|
source: "Generic",
|
|
description: "A generic attachment",
|
|
text: "",
|
|
};
|
|
}
|
|
}
|