mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
feat(audio): add podcast generation and delivery toolkit (#1970)
This commit is contained in:
@@ -7,6 +7,7 @@
|
||||
*/
|
||||
export {
|
||||
clearRequestLog,
|
||||
emitMockAgentAudioStream,
|
||||
getMockBehavior,
|
||||
getRequestLog,
|
||||
resetMockBehavior,
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
import { waitForApp } from '../helpers/app-helpers';
|
||||
import { callOpenhumanRpc } from '../helpers/core-rpc';
|
||||
import { resetApp } from '../helpers/reset-app';
|
||||
import { startMockServer, stopMockServer } from '../mock-server';
|
||||
|
||||
const USER_ID = 'e2e-audio-toolkit';
|
||||
|
||||
describe('Audio toolkit flow', () => {
|
||||
before(async () => {
|
||||
await startMockServer();
|
||||
await waitForApp();
|
||||
await resetApp(USER_ID);
|
||||
});
|
||||
|
||||
after(async () => {
|
||||
await stopMockServer();
|
||||
});
|
||||
|
||||
it('generates an mp3 artifact and captures the email attachment in the workspace', async () => {
|
||||
const response = await callOpenhumanRpc<{
|
||||
result: {
|
||||
audio: { output_path: string; file_name: string; bytes_written: number; format: string };
|
||||
email: { mode: string; capture_path?: string | null; attachment_name: string };
|
||||
};
|
||||
}>('openhuman.audio_toolkit_generate_and_email_podcast', {
|
||||
text: 'This is the weekly AI podcast briefing for the team.',
|
||||
title: 'Weekly briefing',
|
||||
to: 'listener@example.com',
|
||||
subject: 'Your weekly audio briefing',
|
||||
body: 'Attached is the latest audio briefing.',
|
||||
format: 'mp3',
|
||||
});
|
||||
|
||||
expect(response.ok).toBe(true);
|
||||
const result = (response.result?.result ?? response.result) as
|
||||
| {
|
||||
audio: { output_path: string; file_name: string; bytes_written: number; format: string };
|
||||
email: { mode: string; capture_path?: string | null; attachment_name: string };
|
||||
}
|
||||
| undefined;
|
||||
expect(result?.audio.format).toBe('mp3');
|
||||
expect(result?.audio.bytes_written).toBeGreaterThan(0);
|
||||
expect(result?.email.mode).toBe('capture');
|
||||
expect(result?.email.capture_path).toBeTruthy();
|
||||
|
||||
const workspaceFiles = await callOpenhumanRpc<{
|
||||
result: { entries: Array<{ rel_path: string; size: number; is_dir: boolean }> };
|
||||
}>('openhuman.test_support_list_workspace_files', { rel_root: 'artifacts', max_depth: 4 });
|
||||
expect(workspaceFiles.ok).toBe(true);
|
||||
const entries =
|
||||
(
|
||||
(workspaceFiles.result?.result ?? workspaceFiles.result) as
|
||||
| { entries?: Array<{ rel_path: string; size: number; is_dir: boolean }> }
|
||||
| undefined
|
||||
)?.entries ?? [];
|
||||
const audioArtifact = entries.find(
|
||||
e =>
|
||||
e.rel_path === result?.audio.output_path ||
|
||||
!!result?.audio.output_path?.endsWith(`/${e.rel_path}`) ||
|
||||
e.rel_path.endsWith(`/${result?.audio.output_path ?? ''}`)
|
||||
);
|
||||
expect(audioArtifact?.size ?? 0).toBeGreaterThan(0);
|
||||
const capturedEmail = entries.find(
|
||||
e =>
|
||||
e.rel_path === result?.email.capture_path ||
|
||||
!!result?.email.capture_path?.endsWith(`/${e.rel_path}`) ||
|
||||
e.rel_path.endsWith(`/${result?.email.capture_path ?? ''}`)
|
||||
);
|
||||
expect(capturedEmail?.size ?? 0).toBeGreaterThan(0);
|
||||
|
||||
const emailRead = await callOpenhumanRpc<{ result: { content_utf8: string } }>(
|
||||
'openhuman.test_support_read_workspace_file',
|
||||
{ rel_path: result?.email.capture_path, max_bytes: 131072 }
|
||||
);
|
||||
expect(emailRead.ok).toBe(true);
|
||||
const wire =
|
||||
((emailRead.result?.result ?? emailRead.result) as { content_utf8?: string } | undefined)
|
||||
?.content_utf8 ?? '';
|
||||
expect(wire).toContain('Subject: Your weekly audio briefing');
|
||||
expect(wire).toContain(result?.email.attachment_name ?? 'weekly-briefing.mp3');
|
||||
});
|
||||
});
|
||||
@@ -8,6 +8,7 @@
|
||||
export {
|
||||
DEFAULT_PORT,
|
||||
clearRequestLog,
|
||||
emitMockAgentAudioStream,
|
||||
getMockBehavior,
|
||||
getMockServerPort,
|
||||
getRequestLog,
|
||||
|
||||
@@ -27,4 +27,8 @@ export {
|
||||
setMockBehavior,
|
||||
setMockBehaviors,
|
||||
} from "./state.mjs";
|
||||
export { disconnectMockSockets, emitMockSocketEvent } from "./socket.mjs";
|
||||
export {
|
||||
disconnectMockSockets,
|
||||
emitMockAgentAudioStream,
|
||||
emitMockSocketEvent,
|
||||
} from "./socket.mjs";
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { resetMockBehavior, setMockBehavior } from "../../state.mjs";
|
||||
import { handleAudio } from "../audio.mjs";
|
||||
|
||||
function createRes() {
|
||||
return {
|
||||
statusCode: 0,
|
||||
headers: {},
|
||||
body: "",
|
||||
writeHead(status, headers = {}) {
|
||||
this.statusCode = status;
|
||||
this.headers = headers;
|
||||
},
|
||||
setHeader(name, value) {
|
||||
this.headers[name] = value;
|
||||
},
|
||||
end(chunk = "") {
|
||||
this.body += String(chunk);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test.beforeEach(() => {
|
||||
resetMockBehavior();
|
||||
});
|
||||
|
||||
test("mock audio speech route returns audio + visemes", async () => {
|
||||
const res = createRes();
|
||||
const handled = await handleAudio({
|
||||
method: "POST",
|
||||
url: "/openai/v1/audio/speech",
|
||||
parsedBody: {
|
||||
text: "hello world",
|
||||
with_visemes: true,
|
||||
},
|
||||
res,
|
||||
});
|
||||
|
||||
assert.equal(handled, true);
|
||||
assert.equal(res.statusCode, 200);
|
||||
const payload = JSON.parse(res.body);
|
||||
assert.equal(payload.audio_mime, "audio/mpeg");
|
||||
assert.ok(
|
||||
typeof payload.audio_base64 === "string" && payload.audio_base64.length > 0,
|
||||
);
|
||||
assert.ok(Array.isArray(payload.visemes) && payload.visemes.length > 0);
|
||||
assert.ok(Array.isArray(payload.alignment) && payload.alignment.length > 0);
|
||||
});
|
||||
|
||||
test("mock audio speech route honors behavior overrides", async () => {
|
||||
setMockBehavior("audioSpeechMime", "audio/wav");
|
||||
setMockBehavior(
|
||||
"audioSpeechBase64",
|
||||
Buffer.from("WAVMOCK", "utf8").toString("base64"),
|
||||
);
|
||||
|
||||
const res = createRes();
|
||||
await handleAudio({
|
||||
method: "POST",
|
||||
url: "/openai/v1/audio/speech",
|
||||
parsedBody: { text: "override" },
|
||||
res,
|
||||
});
|
||||
|
||||
const payload = JSON.parse(res.body);
|
||||
assert.equal(payload.audio_mime, "audio/wav");
|
||||
assert.equal(
|
||||
Buffer.from(payload.audio_base64, "base64").toString("utf8"),
|
||||
"WAVMOCK",
|
||||
);
|
||||
});
|
||||
|
||||
test("mock audio transcription route returns deterministic text", async () => {
|
||||
setMockBehavior("audioTranscriptionText", "Podcast transcription.");
|
||||
const res = createRes();
|
||||
const handled = await handleAudio({
|
||||
method: "POST",
|
||||
url: "/openai/v1/audio/transcriptions",
|
||||
parsedBody: {},
|
||||
res,
|
||||
});
|
||||
|
||||
assert.equal(handled, true);
|
||||
assert.equal(JSON.parse(res.body).text, "Podcast transcription.");
|
||||
});
|
||||
@@ -0,0 +1,72 @@
|
||||
import { json } from "../http.mjs";
|
||||
import { behavior, parseBehaviorJson } from "../state.mjs";
|
||||
|
||||
const DEFAULT_AUDIO_BYTES = Buffer.from("ID3MOCKAUDIO", "utf8");
|
||||
|
||||
function defaultVisemes() {
|
||||
return [
|
||||
{ viseme: "sil", start_ms: 0, end_ms: 40 },
|
||||
{ viseme: "aa", start_ms: 40, end_ms: 240 },
|
||||
];
|
||||
}
|
||||
|
||||
function defaultAlignment(text) {
|
||||
const chars = String(text || "ok")
|
||||
.slice(0, 8)
|
||||
.split("");
|
||||
return chars.map((char, index) => ({
|
||||
char,
|
||||
start_ms: index * 80,
|
||||
end_ms: index * 80 + 80,
|
||||
}));
|
||||
}
|
||||
|
||||
export async function handleAudio(ctx) {
|
||||
const { method, url, parsedBody, res } = ctx;
|
||||
|
||||
if (method === "POST" && /^\/openai\/v1\/audio\/speech\/?$/.test(url)) {
|
||||
const text = String(parsedBody?.text || "");
|
||||
const mockBehavior = behavior();
|
||||
const visemes = parseBehaviorJson("audioSpeechVisemes", defaultVisemes());
|
||||
const alignment = parseBehaviorJson(
|
||||
"audioSpeechAlignment",
|
||||
defaultAlignment(text),
|
||||
);
|
||||
const audioBytes = mockBehavior.audioSpeechBase64
|
||||
? Buffer.from(String(mockBehavior.audioSpeechBase64), "base64")
|
||||
: DEFAULT_AUDIO_BYTES;
|
||||
json(res, 200, {
|
||||
audio_base64: audioBytes.toString("base64"),
|
||||
audio_mime: mockBehavior.audioSpeechMime || "audio/mpeg",
|
||||
visemes:
|
||||
parsedBody?.with_visemes === true || parsedBody?.with_alignment === true
|
||||
? visemes
|
||||
: [],
|
||||
alignment:
|
||||
parsedBody?.with_alignment === true || parsedBody?.with_visemes === true
|
||||
? alignment
|
||||
: undefined,
|
||||
voice_id:
|
||||
parsedBody?.voice_id || mockBehavior.audioSpeechVoiceId || "mock-voice",
|
||||
model_id:
|
||||
parsedBody?.model_id ||
|
||||
mockBehavior.audioSpeechModelId ||
|
||||
"mock-tts-v1",
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
if (
|
||||
method === "POST" &&
|
||||
/^\/openai\/v1\/audio\/transcriptions\/?$/.test(url)
|
||||
) {
|
||||
json(res, 200, {
|
||||
text:
|
||||
behavior().audioTranscriptionText ||
|
||||
"Mock transcription from the E2E server.",
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
tryParseJson,
|
||||
} from "./http.mjs";
|
||||
import { handleAuth } from "./routes/auth.mjs";
|
||||
import { handleAudio } from "./routes/audio.mjs";
|
||||
import { handleConversations } from "./routes/conversations.mjs";
|
||||
import { handleCron } from "./routes/cron.mjs";
|
||||
import { handleIntegrations } from "./routes/integrations.mjs";
|
||||
@@ -42,6 +43,7 @@ const ROUTE_HANDLERS = [
|
||||
handleUser,
|
||||
handleInvites,
|
||||
handlePayments,
|
||||
handleAudio,
|
||||
// LLM completions must run before the catch-all stub in
|
||||
// `handleIntegrations` so keyword-driven test scripts can override
|
||||
// the default "Hello from e2e mock agent" reply.
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
export {
|
||||
disconnectMockSockets,
|
||||
emitMockAgentAudioStream,
|
||||
emitMockSocketEvent,
|
||||
handleSocketRequest,
|
||||
handleWebSocketUpgrade,
|
||||
|
||||
@@ -5,6 +5,7 @@ import test from "node:test";
|
||||
import {
|
||||
clearSocketEventLog,
|
||||
disconnectMockSockets,
|
||||
emitMockAgentAudioStream,
|
||||
emitMockSocketEvent,
|
||||
listSocketSessions,
|
||||
resetMockBehavior,
|
||||
@@ -101,6 +102,45 @@ test("supports polling-only clients", async () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("streams mock agent audio events through a real socket.io client", async () => {
|
||||
const started = await startMockServer(18575, { retryIfInUse: true });
|
||||
const baseUrl = `http://127.0.0.1:${started.port}`;
|
||||
const socket = createSocket(baseUrl, {
|
||||
auth: { token: "mock-jwt-token" },
|
||||
transports: ["polling", "websocket"],
|
||||
});
|
||||
|
||||
try {
|
||||
await onceSocket(socket, "ready");
|
||||
const startPromise = onceSocket(socket, "agent:audio:start");
|
||||
const chunkPromise = onceSocket(socket, "agent:audio:chunk");
|
||||
const endPromise = onceSocket(socket, "agent:audio:end");
|
||||
|
||||
const delivered = emitMockAgentAudioStream({
|
||||
sessionId: "session-audio-1",
|
||||
text: "listen now",
|
||||
voiceId: "voice-1",
|
||||
chunks: ["SUQz", "TU9DSw=="],
|
||||
chunkDelayMs: 5,
|
||||
});
|
||||
assert.equal(delivered, 1);
|
||||
|
||||
const startedPayload = await startPromise;
|
||||
assert.equal(startedPayload.sessionId, "session-audio-1");
|
||||
assert.equal(startedPayload.voiceId, "voice-1");
|
||||
assert.equal(startedPayload.contentType, "audio/mpeg");
|
||||
|
||||
const chunkPayload = await chunkPromise;
|
||||
assert.equal(chunkPayload.chunk, "SUQz");
|
||||
|
||||
const endedPayload = await endPromise;
|
||||
assert.equal(endedPayload.sessionId, "session-audio-1");
|
||||
assert.equal(endedPayload.ttsCharCount, "listen now".length);
|
||||
} finally {
|
||||
socket.disconnect();
|
||||
}
|
||||
});
|
||||
|
||||
test("keeps polling session alive when websocket probe closes before upgrade", () => {
|
||||
const session = registerSocketSession({
|
||||
sid: "probe-fallback-sid",
|
||||
|
||||
@@ -127,6 +127,13 @@ function scheduleMockSocketActions(session, actions = []) {
|
||||
: (action.data ?? null),
|
||||
targetSid: session.sid,
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (action?.agentAudioStream) {
|
||||
emitMockAgentAudioStream({
|
||||
targetSid: session.sid,
|
||||
...action.agentAudioStream,
|
||||
});
|
||||
}
|
||||
}, delayMs);
|
||||
}
|
||||
@@ -407,6 +414,75 @@ export function emitMockSocketEvent({
|
||||
return matchingSessions.length;
|
||||
}
|
||||
|
||||
export function emitMockAgentAudioStream({
|
||||
sessionId = "mock-agent-session",
|
||||
requestId,
|
||||
text = "",
|
||||
voiceId = "",
|
||||
contentType = "audio/mpeg",
|
||||
chunks,
|
||||
chunkDelayMs = 0,
|
||||
targetSid,
|
||||
targetUserId,
|
||||
excludeSid,
|
||||
}) {
|
||||
const resolvedRequestId = requestId || `mock-audio-${createMockId("req")}`;
|
||||
const normalizedChunks =
|
||||
Array.isArray(chunks) && chunks.length > 0
|
||||
? chunks
|
||||
: [Buffer.from("ID3MOCKAUDIO", "utf8").toString("base64")];
|
||||
|
||||
let delivered = emitMockSocketEvent({
|
||||
event: "agent:audio:start",
|
||||
data: {
|
||||
sessionId,
|
||||
requestId: resolvedRequestId,
|
||||
contentType,
|
||||
voiceId,
|
||||
text,
|
||||
},
|
||||
targetSid,
|
||||
targetUserId,
|
||||
excludeSid,
|
||||
});
|
||||
|
||||
normalizedChunks.forEach((chunk, index) => {
|
||||
delivered = Math.max(
|
||||
delivered,
|
||||
emitMockSocketEvent({
|
||||
event: "agent:audio:chunk",
|
||||
data: {
|
||||
sessionId,
|
||||
requestId: resolvedRequestId,
|
||||
chunk,
|
||||
},
|
||||
targetSid,
|
||||
targetUserId,
|
||||
excludeSid,
|
||||
delayMs: chunkDelayMs * (index + 1),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
delivered = Math.max(
|
||||
delivered,
|
||||
emitMockSocketEvent({
|
||||
event: "agent:audio:end",
|
||||
data: {
|
||||
sessionId,
|
||||
requestId: resolvedRequestId,
|
||||
ttsCharCount: String(text || "").length,
|
||||
},
|
||||
targetSid,
|
||||
targetUserId,
|
||||
excludeSid,
|
||||
delayMs: chunkDelayMs * (normalizedChunks.length + 1),
|
||||
}),
|
||||
);
|
||||
|
||||
return delivered;
|
||||
}
|
||||
|
||||
export function disconnectMockSockets({ targetSid, targetUserId } = {}) {
|
||||
let disconnected = 0;
|
||||
for (const sessionInfo of listSocketSessions()) {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
export {
|
||||
disconnectMockSockets,
|
||||
emitMockAgentAudioStream,
|
||||
emitMockSocketEvent,
|
||||
handleSocketRequest,
|
||||
handleWebSocketUpgrade,
|
||||
|
||||
@@ -108,6 +108,8 @@ fn build_registered_controllers() -> Vec<RegisteredController> {
|
||||
controllers.extend(crate::openhuman::about_app::all_about_app_registered_controllers());
|
||||
// Core application shell state
|
||||
controllers.extend(crate::openhuman::app_state::all_app_state_registered_controllers());
|
||||
// Audio generation + podcast-style email delivery
|
||||
controllers.extend(crate::openhuman::audio_toolkit::all_audio_toolkit_registered_controllers());
|
||||
// Composio integration controllers
|
||||
controllers.extend(crate::openhuman::composio::all_composio_registered_controllers());
|
||||
// Scheduled job management
|
||||
@@ -251,6 +253,7 @@ fn build_declared_controller_schemas() -> Vec<ControllerSchema> {
|
||||
let mut schemas = Vec::new();
|
||||
schemas.extend(crate::openhuman::about_app::all_about_app_controller_schemas());
|
||||
schemas.extend(crate::openhuman::app_state::all_app_state_controller_schemas());
|
||||
schemas.extend(crate::openhuman::audio_toolkit::all_audio_toolkit_controller_schemas());
|
||||
schemas.extend(crate::openhuman::composio::all_composio_controller_schemas());
|
||||
schemas.extend(crate::openhuman::cron::all_cron_controller_schemas());
|
||||
schemas.extend(crate::openhuman::webview_apis::all_webview_apis_controller_schemas());
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
mod ops;
|
||||
mod schemas;
|
||||
mod types;
|
||||
|
||||
pub use ops::{
|
||||
email_podcast, generate_and_email_podcast, generate_podcast, resolve_email_capture_dir,
|
||||
};
|
||||
pub use schemas::{all_audio_toolkit_controller_schemas, all_audio_toolkit_registered_controllers};
|
||||
pub use types::{
|
||||
AudioEmailDeliveryResult, AudioFormat, AudioGenerateRequest, AudioGeneratedArtifact,
|
||||
AudioToolkitGenerateAndEmailResult, EmailPodcastRequest,
|
||||
};
|
||||
@@ -0,0 +1,515 @@
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use base64::{engine::general_purpose::STANDARD as BASE64, Engine};
|
||||
use lettre::message::{header::ContentType, Attachment, Mailbox, MultiPart, SinglePart};
|
||||
use lettre::Message;
|
||||
|
||||
use crate::openhuman::channels::email_channel::EmailChannel;
|
||||
use crate::openhuman::config::Config;
|
||||
use crate::openhuman::voice::{create_tts_provider, DEFAULT_PIPER_VOICE};
|
||||
use crate::rpc::RpcOutcome;
|
||||
|
||||
use super::types::{
|
||||
AudioEmailDeliveryResult, AudioFormat, AudioGenerateRequest, AudioGeneratedArtifact,
|
||||
AudioToolkitGenerateAndEmailResult, EmailPodcastRequest,
|
||||
};
|
||||
|
||||
const LOG_PREFIX: &str = "[audio_toolkit]";
|
||||
const DEFAULT_OUTPUT_DIR: &str = "artifacts/audio";
|
||||
const DEFAULT_CAPTURE_DIR: &str = "artifacts/email-capture";
|
||||
const EMAIL_CAPTURE_ENV: &str = "OPENHUMAN_EMAIL_CAPTURE_DIR";
|
||||
|
||||
pub async fn generate_podcast(
|
||||
config: &Config,
|
||||
request: AudioGenerateRequest,
|
||||
) -> Result<RpcOutcome<AudioGeneratedArtifact>, String> {
|
||||
let trimmed = request.text.trim();
|
||||
if trimmed.is_empty() {
|
||||
return Err("text is required".to_string());
|
||||
}
|
||||
|
||||
let provider = effective_provider_name(config, request.provider.as_deref());
|
||||
let format = resolve_format(&provider, request.format)?;
|
||||
let voice = effective_voice(&provider, request.voice.as_deref());
|
||||
let output_rel_path = resolve_output_path(
|
||||
config.workspace_dir.as_path(),
|
||||
request.output_path.as_deref(),
|
||||
request.title.as_deref(),
|
||||
format,
|
||||
)?;
|
||||
|
||||
log::debug!(
|
||||
"{LOG_PREFIX} generate provider={} format={:?} output_path={}",
|
||||
provider,
|
||||
format,
|
||||
output_rel_path.display()
|
||||
);
|
||||
|
||||
let provider_impl = create_tts_provider(&provider, voice.as_deref().unwrap_or(""), config)
|
||||
.map_err(|e| e.to_string())?;
|
||||
let outcome = provider_impl
|
||||
.synthesize(config, trimmed, voice.as_deref())
|
||||
.await?;
|
||||
|
||||
let bytes = decode_audio_payload(&outcome.value.audio_base64)?;
|
||||
enforce_audio_format(format, &outcome.value.audio_mime)?;
|
||||
let resolved = config.workspace_dir.join(&output_rel_path);
|
||||
if let Some(parent) = resolved.parent() {
|
||||
tokio::fs::create_dir_all(parent)
|
||||
.await
|
||||
.map_err(|e| format!("failed to create {}: {e}", parent.display()))?;
|
||||
}
|
||||
tokio::fs::write(&resolved, &bytes)
|
||||
.await
|
||||
.map_err(|e| format!("failed to write {}: {e}", resolved.display()))?;
|
||||
|
||||
let result = AudioGeneratedArtifact {
|
||||
output_path: output_rel_path.to_string_lossy().to_string(),
|
||||
file_name: resolved
|
||||
.file_name()
|
||||
.map(|v| v.to_string_lossy().to_string())
|
||||
.unwrap_or_else(|| format!("podcast.{}", format.extension())),
|
||||
provider,
|
||||
voice,
|
||||
format,
|
||||
audio_mime: outcome.value.audio_mime,
|
||||
bytes_written: bytes.len(),
|
||||
chars_synthesized: trimmed.chars().count(),
|
||||
};
|
||||
|
||||
Ok(RpcOutcome::single_log(
|
||||
result,
|
||||
"audio podcast synthesized to workspace file",
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn email_podcast(
|
||||
config: &Config,
|
||||
request: EmailPodcastRequest,
|
||||
) -> Result<RpcOutcome<AudioEmailDeliveryResult>, String> {
|
||||
let to = request.to.trim();
|
||||
let subject = request.subject.trim();
|
||||
let body = request.body.trim();
|
||||
let audio_path = request.audio_path.trim();
|
||||
|
||||
if to.is_empty() {
|
||||
return Err("to is required".to_string());
|
||||
}
|
||||
if subject.is_empty() {
|
||||
return Err("subject is required".to_string());
|
||||
}
|
||||
if body.is_empty() {
|
||||
return Err("body is required".to_string());
|
||||
}
|
||||
if audio_path.is_empty() {
|
||||
return Err("audio_path is required".to_string());
|
||||
}
|
||||
|
||||
let rel_audio_path = PathBuf::from(audio_path);
|
||||
if rel_audio_path.is_absolute() {
|
||||
return Err("audio_path must be workspace-relative".to_string());
|
||||
}
|
||||
if rel_audio_path
|
||||
.components()
|
||||
.any(|c| matches!(c, std::path::Component::ParentDir))
|
||||
{
|
||||
return Err("audio_path must not contain parent-directory traversal".to_string());
|
||||
}
|
||||
|
||||
let resolved_audio_path = config.workspace_dir.join(&rel_audio_path);
|
||||
let audio_bytes = tokio::fs::read(&resolved_audio_path)
|
||||
.await
|
||||
.map_err(|e| format!("failed to read {}: {e}", resolved_audio_path.display()))?;
|
||||
let attachment_name = request
|
||||
.attachment_name
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(str::to_string)
|
||||
.or_else(|| {
|
||||
resolved_audio_path
|
||||
.file_name()
|
||||
.map(|v| v.to_string_lossy().to_string())
|
||||
})
|
||||
.unwrap_or_else(|| "podcast.mp3".to_string());
|
||||
let content_type = content_type_for_attachment(&attachment_name)?;
|
||||
|
||||
let email = build_email_message(
|
||||
config,
|
||||
to,
|
||||
subject,
|
||||
body,
|
||||
&attachment_name,
|
||||
content_type,
|
||||
audio_bytes,
|
||||
)?;
|
||||
|
||||
if let Some(capture_dir) = resolve_email_capture_dir(config) {
|
||||
let capture_rel =
|
||||
capture_email_message(config.workspace_dir.as_path(), &capture_dir, &email).await?;
|
||||
let result = AudioEmailDeliveryResult {
|
||||
to: to.to_string(),
|
||||
subject: subject.to_string(),
|
||||
attachment_name,
|
||||
mode: "capture".to_string(),
|
||||
capture_path: Some(capture_rel),
|
||||
};
|
||||
return Ok(RpcOutcome::single_log(
|
||||
result,
|
||||
"audio email captured to workspace file",
|
||||
));
|
||||
}
|
||||
|
||||
let email_cfg = config
|
||||
.channels_config
|
||||
.email
|
||||
.clone()
|
||||
.ok_or_else(|| "email channel is not configured".to_string())?;
|
||||
let channel = EmailChannel::new(email_cfg);
|
||||
channel
|
||||
.send_message(email)
|
||||
.map_err(|e| format!("failed to send email attachment: {e}"))?;
|
||||
|
||||
Ok(RpcOutcome::single_log(
|
||||
AudioEmailDeliveryResult {
|
||||
to: to.to_string(),
|
||||
subject: subject.to_string(),
|
||||
attachment_name,
|
||||
mode: "smtp".to_string(),
|
||||
capture_path: None,
|
||||
},
|
||||
"audio email delivered over SMTP",
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn generate_and_email_podcast(
|
||||
config: &Config,
|
||||
generate_request: AudioGenerateRequest,
|
||||
email_request: EmailPodcastRequest,
|
||||
) -> Result<RpcOutcome<AudioToolkitGenerateAndEmailResult>, String> {
|
||||
let generated = generate_podcast(config, generate_request).await?;
|
||||
let email_request = EmailPodcastRequest {
|
||||
audio_path: generated.value.output_path.clone(),
|
||||
..email_request
|
||||
};
|
||||
let emailed = email_podcast(config, email_request).await?;
|
||||
Ok(RpcOutcome::single_log(
|
||||
AudioToolkitGenerateAndEmailResult {
|
||||
audio: generated.value,
|
||||
email: emailed.value,
|
||||
},
|
||||
"audio podcast generated and emailed",
|
||||
))
|
||||
}
|
||||
|
||||
pub fn resolve_email_capture_dir(config: &Config) -> Option<PathBuf> {
|
||||
if let Ok(raw) = std::env::var(EMAIL_CAPTURE_ENV) {
|
||||
let trimmed = raw.trim();
|
||||
if !trimmed.is_empty() {
|
||||
return Some(PathBuf::from(trimmed));
|
||||
}
|
||||
}
|
||||
#[cfg(feature = "e2e-test-support")]
|
||||
{
|
||||
return Some(config.workspace_dir.join(DEFAULT_CAPTURE_DIR));
|
||||
}
|
||||
#[cfg(not(feature = "e2e-test-support"))]
|
||||
{
|
||||
let _ = config;
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn effective_provider_name(config: &Config, requested: Option<&str>) -> String {
|
||||
requested
|
||||
.map(str::trim)
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(str::to_string)
|
||||
.unwrap_or_else(|| {
|
||||
let configured = config.local_ai.tts_provider.trim();
|
||||
if configured.is_empty() {
|
||||
"cloud".to_string()
|
||||
} else {
|
||||
configured.to_string()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn effective_voice(provider: &str, requested: Option<&str>) -> Option<String> {
|
||||
let explicit = requested
|
||||
.map(str::trim)
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(str::to_string);
|
||||
if explicit.is_some() {
|
||||
return explicit;
|
||||
}
|
||||
if provider == "piper" {
|
||||
return Some(DEFAULT_PIPER_VOICE.to_string());
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn resolve_format(provider: &str, requested: Option<AudioFormat>) -> Result<AudioFormat, String> {
|
||||
let resolved = requested.unwrap_or_else(|| {
|
||||
if provider == "piper" {
|
||||
AudioFormat::Wav
|
||||
} else {
|
||||
AudioFormat::Mp3
|
||||
}
|
||||
});
|
||||
match (provider, resolved) {
|
||||
("piper", AudioFormat::Mp3) => {
|
||||
Err("provider `piper` only supports wav output; use format=`wav` or provider=`cloud`".to_string())
|
||||
}
|
||||
("cloud", AudioFormat::Wav) => {
|
||||
Err("provider `cloud` currently returns mp3 output only; use format=`mp3` or provider=`piper`".to_string())
|
||||
}
|
||||
_ => Ok(resolved),
|
||||
}
|
||||
}
|
||||
|
||||
fn decode_audio_payload(audio_base64: &str) -> Result<Vec<u8>, String> {
|
||||
BASE64
|
||||
.decode(audio_base64.trim())
|
||||
.map_err(|e| format!("invalid audio_base64 payload: {e}"))
|
||||
}
|
||||
|
||||
fn enforce_audio_format(format: AudioFormat, mime: &str) -> Result<(), String> {
|
||||
let normalized = mime.trim().to_ascii_lowercase();
|
||||
match format {
|
||||
AudioFormat::Mp3 if normalized == "audio/mpeg" => Ok(()),
|
||||
AudioFormat::Wav if normalized == "audio/wav" => Ok(()),
|
||||
_ => Err(format!(
|
||||
"provider returned mime `{mime}` but requested format was `{}`",
|
||||
format.extension()
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve_output_path(
|
||||
workspace_dir: &Path,
|
||||
requested: Option<&str>,
|
||||
title: Option<&str>,
|
||||
format: AudioFormat,
|
||||
) -> Result<PathBuf, String> {
|
||||
let rel_path = match requested.map(str::trim).filter(|s| !s.is_empty()) {
|
||||
Some(path) => PathBuf::from(path),
|
||||
None => {
|
||||
let slug = slugify_title(title.unwrap_or("podcast"));
|
||||
PathBuf::from(DEFAULT_OUTPUT_DIR).join(format!(
|
||||
"{}-{}.{}",
|
||||
chrono::Utc::now().format("%Y%m%d-%H%M%S"),
|
||||
slug,
|
||||
format.extension()
|
||||
))
|
||||
}
|
||||
};
|
||||
if rel_path.is_absolute() {
|
||||
return Err("output_path must be workspace-relative".to_string());
|
||||
}
|
||||
if rel_path
|
||||
.components()
|
||||
.any(|c| matches!(c, std::path::Component::ParentDir))
|
||||
{
|
||||
return Err("output_path must not contain parent-directory traversal".to_string());
|
||||
}
|
||||
let resolved = workspace_dir.join(&rel_path);
|
||||
if !resolved.starts_with(workspace_dir) {
|
||||
return Err("output_path escapes the workspace".to_string());
|
||||
}
|
||||
Ok(rel_path)
|
||||
}
|
||||
|
||||
fn slugify_title(title: &str) -> String {
|
||||
let mut out = String::new();
|
||||
let mut last_was_dash = false;
|
||||
for ch in title.chars() {
|
||||
if ch.is_ascii_alphanumeric() {
|
||||
out.push(ch.to_ascii_lowercase());
|
||||
last_was_dash = false;
|
||||
} else if !last_was_dash && !out.is_empty() {
|
||||
out.push('-');
|
||||
last_was_dash = true;
|
||||
}
|
||||
}
|
||||
let trimmed = out.trim_matches('-');
|
||||
if trimmed.is_empty() {
|
||||
"podcast".to_string()
|
||||
} else {
|
||||
trimmed.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
fn content_type_for_attachment(file_name: &str) -> Result<ContentType, String> {
|
||||
let mime = if file_name.to_ascii_lowercase().ends_with(".wav") {
|
||||
"audio/wav"
|
||||
} else {
|
||||
"audio/mpeg"
|
||||
};
|
||||
mime.parse()
|
||||
.map_err(|e| format!("invalid content type for attachment: {e}"))
|
||||
}
|
||||
|
||||
fn build_email_message(
|
||||
config: &Config,
|
||||
to: &str,
|
||||
subject: &str,
|
||||
body: &str,
|
||||
attachment_name: &str,
|
||||
content_type: ContentType,
|
||||
attachment_bytes: Vec<u8>,
|
||||
) -> Result<Message, String> {
|
||||
let from_address = config
|
||||
.channels_config
|
||||
.email
|
||||
.as_ref()
|
||||
.map(|cfg| cfg.from_address.trim())
|
||||
.filter(|s| !s.is_empty())
|
||||
.unwrap_or("openhuman@localhost.test");
|
||||
let from: Mailbox = from_address
|
||||
.parse()
|
||||
.map_err(|e| format!("invalid from address `{from_address}`: {e}"))?;
|
||||
let to_mailbox: Mailbox = to
|
||||
.parse()
|
||||
.map_err(|e| format!("invalid to address `{to}`: {e}"))?;
|
||||
let attachment =
|
||||
Attachment::new(attachment_name.to_string()).body(attachment_bytes, content_type);
|
||||
Message::builder()
|
||||
.from(from)
|
||||
.to(to_mailbox)
|
||||
.subject(subject)
|
||||
.multipart(
|
||||
MultiPart::mixed()
|
||||
.singlepart(SinglePart::plain(body.to_string()))
|
||||
.singlepart(attachment),
|
||||
)
|
||||
.map_err(|e| format!("failed to build email message: {e}"))
|
||||
}
|
||||
|
||||
async fn capture_email_message(
|
||||
workspace_dir: &Path,
|
||||
capture_dir: &Path,
|
||||
email: &Message,
|
||||
) -> Result<String, String> {
|
||||
let resolved_dir = if capture_dir.is_absolute() {
|
||||
capture_dir.to_path_buf()
|
||||
} else {
|
||||
workspace_dir.join(capture_dir)
|
||||
};
|
||||
tokio::fs::create_dir_all(&resolved_dir)
|
||||
.await
|
||||
.map_err(|e| format!("failed to create {}: {e}", resolved_dir.display()))?;
|
||||
let file_name = format!(
|
||||
"podcast-email-{}-{}.eml",
|
||||
chrono::Utc::now().format("%Y%m%d-%H%M%S"),
|
||||
uuid::Uuid::new_v4()
|
||||
);
|
||||
let resolved_path = resolved_dir.join(&file_name);
|
||||
tokio::fs::write(&resolved_path, email.formatted())
|
||||
.await
|
||||
.map_err(|e| format!("failed to write {}: {e}", resolved_path.display()))?;
|
||||
let relative = resolved_path
|
||||
.strip_prefix(workspace_dir)
|
||||
.map(|v| v.to_string_lossy().to_string())
|
||||
.unwrap_or_else(|_| resolved_path.to_string_lossy().to_string());
|
||||
Ok(relative)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn test_config() -> Config {
|
||||
let mut config = Config::default();
|
||||
config.workspace_dir = std::env::temp_dir().join("openhuman-audio-toolkit-tests");
|
||||
config.local_ai.tts_provider = "cloud".to_string();
|
||||
config
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_format_defaults_to_mp3_for_cloud() {
|
||||
assert_eq!(resolve_format("cloud", None).unwrap(), AudioFormat::Mp3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_format_defaults_to_wav_for_piper() {
|
||||
assert_eq!(resolve_format("piper", None).unwrap(), AudioFormat::Wav);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_format_rejects_mp3_for_piper() {
|
||||
let err = resolve_format("piper", Some(AudioFormat::Mp3)).unwrap_err();
|
||||
assert!(err.contains("only supports wav"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slugify_title_collapses_noise() {
|
||||
assert_eq!(
|
||||
slugify_title(" Weekly update: Q2 / AI! "),
|
||||
"weekly-update-q2-ai"
|
||||
);
|
||||
assert_eq!(slugify_title("###"), "podcast");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_output_path_rejects_parent_dir() {
|
||||
let err = resolve_output_path(
|
||||
Path::new("/tmp/workspace"),
|
||||
Some("../escape.mp3"),
|
||||
None,
|
||||
AudioFormat::Mp3,
|
||||
)
|
||||
.unwrap_err();
|
||||
assert!(err.contains("parent-directory traversal"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_email_message_includes_attachment_name() {
|
||||
let config = test_config();
|
||||
let message = build_email_message(
|
||||
&config,
|
||||
"listener@example.com",
|
||||
"Podcast",
|
||||
"Attached.",
|
||||
"briefing.mp3",
|
||||
"audio/mpeg".parse().unwrap(),
|
||||
vec![1, 2, 3],
|
||||
)
|
||||
.unwrap();
|
||||
let wire = String::from_utf8_lossy(&message.formatted()).to_string();
|
||||
assert!(wire.contains("Subject: Podcast"));
|
||||
assert!(wire.contains("filename=\"briefing.mp3\""));
|
||||
assert!(wire.contains("Content-Type: audio/mpeg"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_email_capture_dir_uses_workspace_when_e2e_feature_enabled() {
|
||||
let config = test_config();
|
||||
let capture = resolve_email_capture_dir(&config);
|
||||
#[cfg(feature = "e2e-test-support")]
|
||||
assert!(capture.unwrap().ends_with(DEFAULT_CAPTURE_DIR));
|
||||
#[cfg(not(feature = "e2e-test-support"))]
|
||||
assert!(capture.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn effective_voice_defaults_for_piper_only() {
|
||||
assert_eq!(
|
||||
effective_voice("piper", None).as_deref(),
|
||||
Some(DEFAULT_PIPER_VOICE)
|
||||
);
|
||||
assert!(effective_voice("cloud", None).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enforce_audio_format_requires_matching_mime() {
|
||||
assert!(enforce_audio_format(AudioFormat::Mp3, "audio/mpeg").is_ok());
|
||||
assert!(enforce_audio_format(AudioFormat::Mp3, "audio/wav").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decode_audio_payload_rejects_bad_base64() {
|
||||
let err = decode_audio_payload("not-base64").unwrap_err();
|
||||
assert!(err.contains("invalid audio_base64"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
use serde::Deserialize;
|
||||
use serde_json::{Map, Value};
|
||||
|
||||
use crate::core::all::{ControllerFuture, RegisteredController};
|
||||
use crate::core::{ControllerSchema, FieldSchema, TypeSchema};
|
||||
use crate::openhuman::audio_toolkit::types::{
|
||||
AudioFormat, AudioGenerateRequest, EmailPodcastRequest,
|
||||
};
|
||||
use crate::openhuman::config::rpc as config_rpc;
|
||||
use crate::rpc::RpcOutcome;
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct GenerateAndEmailParams {
|
||||
text: String,
|
||||
to: String,
|
||||
subject: String,
|
||||
body: String,
|
||||
#[serde(default)]
|
||||
title: Option<String>,
|
||||
#[serde(default)]
|
||||
output_path: Option<String>,
|
||||
#[serde(default)]
|
||||
provider: Option<String>,
|
||||
#[serde(default)]
|
||||
voice: Option<String>,
|
||||
#[serde(default)]
|
||||
format: Option<AudioFormat>,
|
||||
#[serde(default)]
|
||||
attachment_name: Option<String>,
|
||||
}
|
||||
|
||||
pub fn all_audio_toolkit_controller_schemas() -> Vec<ControllerSchema> {
|
||||
vec![
|
||||
audio_toolkit_schemas("generate_podcast"),
|
||||
audio_toolkit_schemas("email_podcast"),
|
||||
audio_toolkit_schemas("generate_and_email_podcast"),
|
||||
]
|
||||
}
|
||||
|
||||
pub fn all_audio_toolkit_registered_controllers() -> Vec<RegisteredController> {
|
||||
vec![
|
||||
RegisteredController {
|
||||
schema: audio_toolkit_schemas("generate_podcast"),
|
||||
handler: handle_generate_podcast,
|
||||
},
|
||||
RegisteredController {
|
||||
schema: audio_toolkit_schemas("email_podcast"),
|
||||
handler: handle_email_podcast,
|
||||
},
|
||||
RegisteredController {
|
||||
schema: audio_toolkit_schemas("generate_and_email_podcast"),
|
||||
handler: handle_generate_and_email_podcast,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
pub fn audio_toolkit_schemas(function: &str) -> ControllerSchema {
|
||||
match function {
|
||||
"generate_podcast" => ControllerSchema {
|
||||
namespace: "audio_toolkit",
|
||||
function: "generate_podcast",
|
||||
description: "Synthesize text into a workspace audio file for listen-later / podcast-style delivery.",
|
||||
inputs: vec![
|
||||
required_string("text", "Text to synthesize."),
|
||||
optional_string("title", "Optional title used to derive the default file name."),
|
||||
optional_string("output_path", "Optional workspace-relative output path."),
|
||||
optional_string("provider", "Optional TTS provider override (`cloud` or `piper`)."),
|
||||
optional_string("voice", "Optional provider-specific voice id."),
|
||||
FieldSchema {
|
||||
name: "format",
|
||||
ty: TypeSchema::Option(Box::new(TypeSchema::String)),
|
||||
comment: "Optional output format (`mp3` or `wav`).",
|
||||
required: false,
|
||||
},
|
||||
],
|
||||
outputs: vec![json_output("audio", "Generated audio artifact metadata.")],
|
||||
},
|
||||
"email_podcast" => ControllerSchema {
|
||||
namespace: "audio_toolkit",
|
||||
function: "email_podcast",
|
||||
description: "Email a previously generated workspace audio file as an attachment.",
|
||||
inputs: vec![
|
||||
required_string("to", "Destination email address."),
|
||||
required_string("subject", "Email subject line."),
|
||||
required_string("body", "Email body text."),
|
||||
required_string("audio_path", "Workspace-relative path to the audio attachment."),
|
||||
optional_string("attachment_name", "Optional attachment file name override."),
|
||||
],
|
||||
outputs: vec![json_output("email", "Email delivery metadata.")],
|
||||
},
|
||||
"generate_and_email_podcast" => ControllerSchema {
|
||||
namespace: "audio_toolkit",
|
||||
function: "generate_and_email_podcast",
|
||||
description: "Generate an audio file from text and immediately email it as a podcast-style attachment.",
|
||||
inputs: vec![
|
||||
required_string("text", "Text to synthesize."),
|
||||
required_string("to", "Destination email address."),
|
||||
required_string("subject", "Email subject line."),
|
||||
required_string("body", "Email body text."),
|
||||
optional_string("title", "Optional title used for the generated file name."),
|
||||
optional_string("output_path", "Optional workspace-relative output path."),
|
||||
optional_string("provider", "Optional TTS provider override (`cloud` or `piper`)."),
|
||||
optional_string("voice", "Optional provider-specific voice id."),
|
||||
FieldSchema {
|
||||
name: "format",
|
||||
ty: TypeSchema::Option(Box::new(TypeSchema::String)),
|
||||
comment: "Optional output format (`mp3` or `wav`).",
|
||||
required: false,
|
||||
},
|
||||
optional_string("attachment_name", "Optional attachment file name override."),
|
||||
],
|
||||
outputs: vec![json_output("result", "Combined audio generation and email-delivery result.")],
|
||||
},
|
||||
_ => ControllerSchema {
|
||||
namespace: "audio_toolkit",
|
||||
function: "unknown",
|
||||
description: "Unknown audio toolkit controller.",
|
||||
inputs: vec![],
|
||||
outputs: vec![FieldSchema {
|
||||
name: "error",
|
||||
ty: TypeSchema::String,
|
||||
comment: "Lookup error details.",
|
||||
required: true,
|
||||
}],
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_generate_podcast(params: Map<String, Value>) -> ControllerFuture {
|
||||
Box::pin(async move {
|
||||
let config = config_rpc::load_config_with_timeout().await?;
|
||||
let request: AudioGenerateRequest =
|
||||
serde_json::from_value(Value::Object(params)).map_err(|e| e.to_string())?;
|
||||
to_json(crate::openhuman::audio_toolkit::generate_podcast(&config, request).await?)
|
||||
})
|
||||
}
|
||||
|
||||
fn handle_email_podcast(params: Map<String, Value>) -> ControllerFuture {
|
||||
Box::pin(async move {
|
||||
let config = config_rpc::load_config_with_timeout().await?;
|
||||
let request: EmailPodcastRequest =
|
||||
serde_json::from_value(Value::Object(params)).map_err(|e| e.to_string())?;
|
||||
to_json(crate::openhuman::audio_toolkit::email_podcast(&config, request).await?)
|
||||
})
|
||||
}
|
||||
|
||||
fn handle_generate_and_email_podcast(params: Map<String, Value>) -> ControllerFuture {
|
||||
Box::pin(async move {
|
||||
let config = config_rpc::load_config_with_timeout().await?;
|
||||
let request: GenerateAndEmailParams =
|
||||
serde_json::from_value(Value::Object(params)).map_err(|e| e.to_string())?;
|
||||
let generated = AudioGenerateRequest {
|
||||
text: request.text,
|
||||
title: request.title,
|
||||
output_path: request.output_path,
|
||||
provider: request.provider,
|
||||
voice: request.voice,
|
||||
format: request.format,
|
||||
};
|
||||
let email = EmailPodcastRequest {
|
||||
to: request.to,
|
||||
subject: request.subject,
|
||||
body: request.body,
|
||||
audio_path: String::new(),
|
||||
attachment_name: request.attachment_name,
|
||||
};
|
||||
to_json(
|
||||
crate::openhuman::audio_toolkit::generate_and_email_podcast(&config, generated, email)
|
||||
.await?,
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
fn to_json<T: serde::Serialize>(outcome: RpcOutcome<T>) -> Result<Value, String> {
|
||||
serde_json::to_value(outcome.value).map_err(|e| format!("serialize error: {e}"))
|
||||
}
|
||||
|
||||
fn required_string(name: &'static str, comment: &'static str) -> FieldSchema {
|
||||
FieldSchema {
|
||||
name,
|
||||
ty: TypeSchema::String,
|
||||
comment,
|
||||
required: true,
|
||||
}
|
||||
}
|
||||
|
||||
fn optional_string(name: &'static str, comment: &'static str) -> FieldSchema {
|
||||
FieldSchema {
|
||||
name,
|
||||
ty: TypeSchema::Option(Box::new(TypeSchema::String)),
|
||||
comment,
|
||||
required: false,
|
||||
}
|
||||
}
|
||||
|
||||
fn json_output(name: &'static str, comment: &'static str) -> FieldSchema {
|
||||
FieldSchema {
|
||||
name,
|
||||
ty: TypeSchema::Json,
|
||||
comment,
|
||||
required: true,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum AudioFormat {
|
||||
Mp3,
|
||||
Wav,
|
||||
}
|
||||
|
||||
impl AudioFormat {
|
||||
pub fn extension(self) -> &'static str {
|
||||
match self {
|
||||
Self::Mp3 => "mp3",
|
||||
Self::Wav => "wav",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn mime(self) -> &'static str {
|
||||
match self {
|
||||
Self::Mp3 => "audio/mpeg",
|
||||
Self::Wav => "audio/wav",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct AudioGenerateRequest {
|
||||
pub text: String,
|
||||
#[serde(default)]
|
||||
pub title: Option<String>,
|
||||
#[serde(default)]
|
||||
pub output_path: Option<String>,
|
||||
#[serde(default)]
|
||||
pub provider: Option<String>,
|
||||
#[serde(default)]
|
||||
pub voice: Option<String>,
|
||||
#[serde(default)]
|
||||
pub format: Option<AudioFormat>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct EmailPodcastRequest {
|
||||
pub to: String,
|
||||
pub subject: String,
|
||||
pub body: String,
|
||||
pub audio_path: String,
|
||||
#[serde(default)]
|
||||
pub attachment_name: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct AudioGeneratedArtifact {
|
||||
pub output_path: String,
|
||||
pub file_name: String,
|
||||
pub provider: String,
|
||||
pub voice: Option<String>,
|
||||
pub format: AudioFormat,
|
||||
pub audio_mime: String,
|
||||
pub bytes_written: usize,
|
||||
pub chars_synthesized: usize,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct AudioEmailDeliveryResult {
|
||||
pub to: String,
|
||||
pub subject: String,
|
||||
pub attachment_name: String,
|
||||
pub mode: String,
|
||||
#[serde(default)]
|
||||
pub capture_path: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct AudioToolkitGenerateAndEmailResult {
|
||||
pub audio: AudioGeneratedArtifact,
|
||||
pub email: AudioEmailDeliveryResult,
|
||||
}
|
||||
@@ -14,7 +14,7 @@ use async_imap::types::Fetch;
|
||||
use async_imap::Session;
|
||||
use async_trait::async_trait;
|
||||
use futures::TryStreamExt;
|
||||
use lettre::message::SinglePart;
|
||||
use lettre::message::{header::ContentType, Attachment, MultiPart, SinglePart};
|
||||
use lettre::transport::smtp::authentication::Credentials;
|
||||
use lettre::{Message, SmtpTransport, Transport};
|
||||
use mail_parser::{MessageParser, MimeHeaders};
|
||||
@@ -477,6 +477,50 @@ impl EmailChannel {
|
||||
};
|
||||
Ok(transport)
|
||||
}
|
||||
|
||||
pub fn send_message(&self, email: Message) -> Result<()> {
|
||||
let transport = self.create_smtp_transport()?;
|
||||
transport.send(&email)?;
|
||||
info!("Email sent");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn build_plain_message(
|
||||
&self,
|
||||
recipient: &str,
|
||||
subject: &str,
|
||||
body: &str,
|
||||
) -> Result<Message> {
|
||||
Message::builder()
|
||||
.from(self.config.from_address.parse()?)
|
||||
.to(recipient.parse()?)
|
||||
.subject(subject)
|
||||
.singlepart(SinglePart::plain(body.to_string()))
|
||||
.map_err(Into::into)
|
||||
}
|
||||
|
||||
pub fn build_message_with_attachment(
|
||||
&self,
|
||||
recipient: &str,
|
||||
subject: &str,
|
||||
body: &str,
|
||||
attachment_name: &str,
|
||||
content_type: ContentType,
|
||||
attachment_bytes: Vec<u8>,
|
||||
) -> Result<Message> {
|
||||
let attachment =
|
||||
Attachment::new(attachment_name.to_string()).body(attachment_bytes, content_type);
|
||||
Message::builder()
|
||||
.from(self.config.from_address.parse()?)
|
||||
.to(recipient.parse()?)
|
||||
.subject(subject)
|
||||
.multipart(
|
||||
MultiPart::mixed()
|
||||
.singlepart(SinglePart::plain(body.to_string()))
|
||||
.singlepart(attachment),
|
||||
)
|
||||
.map_err(Into::into)
|
||||
}
|
||||
}
|
||||
|
||||
/// Internal struct for parsed email data
|
||||
@@ -515,14 +559,8 @@ impl Channel for EmailChannel {
|
||||
("OpenHuman Message", message.content.as_str())
|
||||
};
|
||||
|
||||
let email = Message::builder()
|
||||
.from(self.config.from_address.parse()?)
|
||||
.to(message.recipient.parse()?)
|
||||
.subject(subject)
|
||||
.singlepart(SinglePart::plain(body.to_string()))?;
|
||||
|
||||
let transport = self.create_smtp_transport()?;
|
||||
transport.send(&email)?;
|
||||
let email = self.build_plain_message(message.recipient.as_str(), subject, body)?;
|
||||
self.send_message(email)?;
|
||||
info!("Email sent to {}", message.recipient);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -112,6 +112,42 @@ fn email_channel_name() {
|
||||
assert_eq!(channel.name(), "email");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_plain_message_uses_subject_and_body() {
|
||||
let channel = EmailChannel::new(EmailConfig {
|
||||
from_address: "bot@example.com".to_string(),
|
||||
..Default::default()
|
||||
});
|
||||
let message = channel
|
||||
.build_plain_message("listener@example.com", "Podcast", "Hello there")
|
||||
.expect("plain message");
|
||||
let wire = String::from_utf8_lossy(&message.formatted()).to_string();
|
||||
assert!(wire.contains("Subject: Podcast"));
|
||||
assert!(wire.contains("Hello there"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_message_with_attachment_adds_audio_part() {
|
||||
let channel = EmailChannel::new(EmailConfig {
|
||||
from_address: "bot@example.com".to_string(),
|
||||
..Default::default()
|
||||
});
|
||||
let message = channel
|
||||
.build_message_with_attachment(
|
||||
"listener@example.com",
|
||||
"Weekly briefing",
|
||||
"Attached.",
|
||||
"briefing.mp3",
|
||||
"audio/mpeg".parse().expect("content type"),
|
||||
vec![1, 2, 3, 4],
|
||||
)
|
||||
.expect("attachment message");
|
||||
let wire = String::from_utf8_lossy(&message.formatted()).to_string();
|
||||
assert!(wire.contains("Subject: Weekly briefing"));
|
||||
assert!(wire.contains("filename=\"briefing.mp3\""));
|
||||
assert!(wire.contains("Content-Type: audio/mpeg"));
|
||||
}
|
||||
|
||||
// is_sender_allowed tests
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -19,6 +19,7 @@ pub mod accessibility;
|
||||
pub mod agent;
|
||||
pub mod app_state;
|
||||
pub mod approval;
|
||||
pub mod audio_toolkit;
|
||||
pub mod autocomplete;
|
||||
pub mod billing;
|
||||
pub mod channels;
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
mod podcast;
|
||||
|
||||
pub use podcast::{
|
||||
AudioEmailPodcastTool, AudioGenerateAndEmailPodcastTool, AudioGeneratePodcastTool,
|
||||
};
|
||||
@@ -0,0 +1,228 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use serde_json::json;
|
||||
|
||||
use crate::openhuman::audio_toolkit::{
|
||||
email_podcast, generate_and_email_podcast, generate_podcast, AudioGenerateRequest,
|
||||
EmailPodcastRequest,
|
||||
};
|
||||
use crate::openhuman::config::Config;
|
||||
use crate::openhuman::security::{SecurityPolicy, ToolOperation};
|
||||
use crate::openhuman::tools::traits::{PermissionLevel, Tool, ToolResult};
|
||||
|
||||
pub struct AudioGeneratePodcastTool {
|
||||
config: Arc<Config>,
|
||||
security: Arc<SecurityPolicy>,
|
||||
}
|
||||
|
||||
pub struct AudioEmailPodcastTool {
|
||||
config: Arc<Config>,
|
||||
security: Arc<SecurityPolicy>,
|
||||
}
|
||||
|
||||
pub struct AudioGenerateAndEmailPodcastTool {
|
||||
config: Arc<Config>,
|
||||
security: Arc<SecurityPolicy>,
|
||||
}
|
||||
|
||||
impl AudioGeneratePodcastTool {
|
||||
pub fn new(config: Arc<Config>, security: Arc<SecurityPolicy>) -> Self {
|
||||
Self { config, security }
|
||||
}
|
||||
}
|
||||
|
||||
impl AudioEmailPodcastTool {
|
||||
pub fn new(config: Arc<Config>, security: Arc<SecurityPolicy>) -> Self {
|
||||
Self { config, security }
|
||||
}
|
||||
}
|
||||
|
||||
impl AudioGenerateAndEmailPodcastTool {
|
||||
pub fn new(config: Arc<Config>, security: Arc<SecurityPolicy>) -> Self {
|
||||
Self { config, security }
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Tool for AudioGeneratePodcastTool {
|
||||
fn name(&self) -> &str {
|
||||
"audio_generate_podcast"
|
||||
}
|
||||
|
||||
fn description(&self) -> &str {
|
||||
"Generate an audio file from text and save it into the workspace for listen-later or podcast-style delivery."
|
||||
}
|
||||
|
||||
fn parameters_schema(&self) -> serde_json::Value {
|
||||
json!({
|
||||
"type": "object",
|
||||
"required": ["text"],
|
||||
"properties": {
|
||||
"text": { "type": "string", "description": "Text to synthesize into audio." },
|
||||
"title": { "type": "string", "description": "Optional title used in the default file name." },
|
||||
"output_path": { "type": "string", "description": "Optional workspace-relative path to write the audio file to." },
|
||||
"provider": { "type": "string", "description": "Optional TTS provider override (`cloud` or `piper`)." },
|
||||
"voice": { "type": "string", "description": "Optional voice id for the chosen provider." },
|
||||
"format": { "type": "string", "enum": ["mp3", "wav"], "description": "Desired audio format." }
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn permission_level(&self) -> PermissionLevel {
|
||||
PermissionLevel::Execute
|
||||
}
|
||||
|
||||
async fn execute(&self, args: serde_json::Value) -> anyhow::Result<ToolResult> {
|
||||
self.security
|
||||
.enforce_tool_operation(ToolOperation::Act, self.name())
|
||||
.map_err(anyhow::Error::msg)?;
|
||||
let request: AudioGenerateRequest = serde_json::from_value(args)?;
|
||||
let outcome = generate_podcast(&self.config, request)
|
||||
.await
|
||||
.map_err(anyhow::Error::msg)?;
|
||||
Ok(ToolResult::success(serde_json::to_string_pretty(
|
||||
&outcome.value,
|
||||
)?))
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Tool for AudioEmailPodcastTool {
|
||||
fn name(&self) -> &str {
|
||||
"audio_email_podcast"
|
||||
}
|
||||
|
||||
fn description(&self) -> &str {
|
||||
"Email a workspace audio file as an attachment so the recipient can listen to it like a podcast episode."
|
||||
}
|
||||
|
||||
fn parameters_schema(&self) -> serde_json::Value {
|
||||
json!({
|
||||
"type": "object",
|
||||
"required": ["to", "subject", "body", "audio_path"],
|
||||
"properties": {
|
||||
"to": { "type": "string", "description": "Recipient email address." },
|
||||
"subject": { "type": "string", "description": "Email subject line." },
|
||||
"body": { "type": "string", "description": "Email body text." },
|
||||
"audio_path": { "type": "string", "description": "Workspace-relative path to the generated audio file." },
|
||||
"attachment_name": { "type": "string", "description": "Optional attachment file name override." }
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn permission_level(&self) -> PermissionLevel {
|
||||
PermissionLevel::Execute
|
||||
}
|
||||
|
||||
async fn execute(&self, args: serde_json::Value) -> anyhow::Result<ToolResult> {
|
||||
self.security
|
||||
.enforce_tool_operation(ToolOperation::Act, self.name())
|
||||
.map_err(anyhow::Error::msg)?;
|
||||
let request: EmailPodcastRequest = serde_json::from_value(args)?;
|
||||
let outcome = email_podcast(&self.config, request)
|
||||
.await
|
||||
.map_err(anyhow::Error::msg)?;
|
||||
Ok(ToolResult::success(serde_json::to_string_pretty(
|
||||
&outcome.value,
|
||||
)?))
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Tool for AudioGenerateAndEmailPodcastTool {
|
||||
fn name(&self) -> &str {
|
||||
"audio_generate_and_email_podcast"
|
||||
}
|
||||
|
||||
fn description(&self) -> &str {
|
||||
"Generate an audio file from text and immediately email it as a podcast-style attachment."
|
||||
}
|
||||
|
||||
fn parameters_schema(&self) -> serde_json::Value {
|
||||
json!({
|
||||
"type": "object",
|
||||
"required": ["text", "to", "subject", "body"],
|
||||
"properties": {
|
||||
"text": { "type": "string", "description": "Text to synthesize into audio." },
|
||||
"to": { "type": "string", "description": "Recipient email address." },
|
||||
"subject": { "type": "string", "description": "Email subject line." },
|
||||
"body": { "type": "string", "description": "Email body text." },
|
||||
"title": { "type": "string", "description": "Optional title used in the default file name." },
|
||||
"output_path": { "type": "string", "description": "Optional workspace-relative output path." },
|
||||
"provider": { "type": "string", "description": "Optional TTS provider override (`cloud` or `piper`)." },
|
||||
"voice": { "type": "string", "description": "Optional voice id for the chosen provider." },
|
||||
"format": { "type": "string", "enum": ["mp3", "wav"], "description": "Desired audio format." },
|
||||
"attachment_name": { "type": "string", "description": "Optional attachment file name override." }
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn permission_level(&self) -> PermissionLevel {
|
||||
PermissionLevel::Execute
|
||||
}
|
||||
|
||||
async fn execute(&self, args: serde_json::Value) -> anyhow::Result<ToolResult> {
|
||||
self.security
|
||||
.enforce_tool_operation(ToolOperation::Act, self.name())
|
||||
.map_err(anyhow::Error::msg)?;
|
||||
|
||||
let text = required_string(&args, "text")?;
|
||||
let to = required_string(&args, "to")?;
|
||||
let subject = required_string(&args, "subject")?;
|
||||
let body = required_string(&args, "body")?;
|
||||
|
||||
let generated = AudioGenerateRequest {
|
||||
text,
|
||||
title: optional_string(&args, "title"),
|
||||
output_path: optional_string(&args, "output_path"),
|
||||
provider: optional_string(&args, "provider"),
|
||||
voice: optional_string(&args, "voice"),
|
||||
format: optional_format(&args, "format")?,
|
||||
};
|
||||
let email = EmailPodcastRequest {
|
||||
to,
|
||||
subject,
|
||||
body,
|
||||
audio_path: String::new(),
|
||||
attachment_name: optional_string(&args, "attachment_name"),
|
||||
};
|
||||
let outcome = generate_and_email_podcast(&self.config, generated, email)
|
||||
.await
|
||||
.map_err(anyhow::Error::msg)?;
|
||||
Ok(ToolResult::success(serde_json::to_string_pretty(
|
||||
&outcome.value,
|
||||
)?))
|
||||
}
|
||||
}
|
||||
|
||||
fn required_string(args: &serde_json::Value, key: &str) -> anyhow::Result<String> {
|
||||
args.get(key)
|
||||
.and_then(|v| v.as_str())
|
||||
.map(str::trim)
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(str::to_string)
|
||||
.ok_or_else(|| anyhow::anyhow!("missing required `{key}`"))
|
||||
}
|
||||
|
||||
fn optional_string(args: &serde_json::Value, key: &str) -> Option<String> {
|
||||
args.get(key)
|
||||
.and_then(|v| v.as_str())
|
||||
.map(str::trim)
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(str::to_string)
|
||||
}
|
||||
|
||||
fn optional_format(
|
||||
args: &serde_json::Value,
|
||||
key: &str,
|
||||
) -> anyhow::Result<Option<crate::openhuman::audio_toolkit::AudioFormat>> {
|
||||
let Some(raw) = args.get(key).and_then(|v| v.as_str()) else {
|
||||
return Ok(None);
|
||||
};
|
||||
match raw.trim() {
|
||||
"mp3" => Ok(Some(crate::openhuman::audio_toolkit::AudioFormat::Mp3)),
|
||||
"wav" => Ok(Some(crate::openhuman::audio_toolkit::AudioFormat::Wav)),
|
||||
other => Err(anyhow::anyhow!("invalid format `{other}`")),
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
pub mod agent;
|
||||
pub mod audio;
|
||||
pub mod browser;
|
||||
pub mod computer;
|
||||
pub mod cron;
|
||||
@@ -9,6 +10,7 @@ pub mod system;
|
||||
pub mod whatsapp_data;
|
||||
|
||||
pub use agent::*;
|
||||
pub use audio::*;
|
||||
pub use browser::*;
|
||||
pub use computer::*;
|
||||
pub use cron::*;
|
||||
|
||||
@@ -155,6 +155,15 @@ pub fn all_tools_with_runtime(
|
||||
security.clone(),
|
||||
workspace_dir.to_path_buf(),
|
||||
)),
|
||||
Box::new(AudioGeneratePodcastTool::new(
|
||||
config.clone(),
|
||||
security.clone(),
|
||||
)),
|
||||
Box::new(AudioEmailPodcastTool::new(config.clone(), security.clone())),
|
||||
Box::new(AudioGenerateAndEmailPodcastTool::new(
|
||||
config.clone(),
|
||||
security.clone(),
|
||||
)),
|
||||
Box::new(GmailUnsubscribeTool),
|
||||
];
|
||||
|
||||
|
||||
Reference in New Issue
Block a user