Signed-off-by: OpenClaw Bot <ai-agent@topdoglabs.com>

This commit is contained in:
OpenClaw Bot 2026-02-26 16:31:13 -06:00
parent e3b47b69ea
commit 0a34e3de47
6 changed files with 168 additions and 10 deletions

24
schema.sql Normal file
View File

@ -0,0 +1,24 @@
-- Blog Backup Database Schema
-- Run this SQL in Supabase Dashboard SQL Editor
-- Add audio URL column for storing podcast audio file URLs
ALTER TABLE blog_messages
ADD COLUMN IF NOT EXISTS audio_url TEXT;
-- Add audio duration column (in seconds)
ALTER TABLE blog_messages
ADD COLUMN IF NOT EXISTS audio_duration INTEGER;
-- Create index for faster queries on posts with audio
CREATE INDEX IF NOT EXISTS idx_blog_messages_audio
ON blog_messages(audio_url)
WHERE audio_url IS NOT NULL;
COMMENT ON COLUMN blog_messages.audio_url IS 'Public URL to the audio file in Supabase Storage';
COMMENT ON COLUMN blog_messages.audio_duration IS 'Audio duration in seconds (estimated from file size)';
-- Verify columns were added
SELECT column_name, data_type
FROM information_schema.columns
WHERE table_name = 'blog_messages'
ORDER BY ordinal_position;

View File

@ -37,18 +37,19 @@ export async function POST(request: Request) {
} }
const id = Date.now().toString(); const id = Date.now().toString();
const newMessage = { const newMessage: Record<string, any> = {
id, id,
date, date,
content, content,
timestamp: Date.now(), timestamp: Date.now(),
tags: tags || ["daily-digest"], tags: tags || ["daily-digest"]
audio_url: null as string | null,
audio_duration: null as number | null,
}; };
// Only add audio fields if they exist in the table
// This handles schema differences between environments
// Save message first (without audio) // Save message first (without audio)
const { error } = await supabase const { error } = await serviceSupabase
.from("blog_messages") .from("blog_messages")
.insert(newMessage); .insert(newMessage);

View File

@ -6,6 +6,11 @@ const supabase = createClient(
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY! process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
); );
const serviceSupabase = createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.SUPABASE_SERVICE_ROLE_KEY!
);
function getBearerToken(request: Request): string | null { function getBearerToken(request: Request): string | null {
const authorization = request.headers.get("authorization"); const authorization = request.headers.get("authorization");
if (!authorization?.startsWith("Bearer ")) { if (!authorization?.startsWith("Bearer ")) {
@ -35,7 +40,7 @@ const CRON_API_KEY = process.env.CRON_API_KEY;
// GET is public (read-only) // GET is public (read-only)
export async function GET() { export async function GET() {
const { data: messages, error } = await supabase const { data: messages, error } = await serviceSupabase
.from("blog_messages") .from("blog_messages")
.select("id, date, content, timestamp, tags") .select("id, date, content, timestamp, tags")
.order("timestamp", { ascending: false }); .order("timestamp", { ascending: false });
@ -74,7 +79,7 @@ export async function POST(request: Request) {
tags: tags || [], tags: tags || [],
}; };
const { error } = await supabase const { error } = await serviceSupabase
.from("blog_messages") .from("blog_messages")
.insert(newMessage); .insert(newMessage);
@ -104,7 +109,7 @@ export async function DELETE(request: Request) {
return NextResponse.json({ error: "ID required" }, { status: 400 }); return NextResponse.json({ error: "ID required" }, { status: 400 });
} }
const { error } = await supabase const { error } = await serviceSupabase
.from("blog_messages") .from("blog_messages")
.delete() .delete()
.eq("id", id); .eq("id", id);

118
src/app/api/tts/route.ts Normal file
View File

@ -0,0 +1,118 @@
/**
* Text-to-Speech API Endpoint
* Generates TTS audio for digest content and returns the audio file
*/
import { NextResponse } from "next/server";
import { generateSpeech, TTSOptions } from "@/lib/tts";
import { uploadAudio } from "@/lib/storage";
import { createClient } from "@supabase/supabase-js";
const serviceSupabase = createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.SUPABASE_SERVICE_ROLE_KEY!
);
/**
* POST /api/tts
* Generate TTS audio for provided text content
*
* Body: { text: string, postId?: string, options?: TTSOptions }
*/
export async function POST(request: Request) {
const apiKey = request.headers.get("x-api-key");
const CRON_API_KEY = process.env.CRON_API_KEY;
if (!CRON_API_KEY || apiKey !== CRON_API_KEY) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
try {
const { text, postId, options } = await request.json();
if (!text) {
return NextResponse.json({ error: "Text content is required" }, { status: 400 });
}
// Generate TTS audio
const { audioBuffer, duration, format } = await generateSpeech(text, {
provider: (process.env.TTS_PROVIDER as "piper" | "openai" | "macsay") || "openai",
voice: process.env.TTS_VOICE || "alloy",
...options,
});
// Determine file extension based on format
const ext = format === "audio/wav" ? "wav" :
format === "audio/aiff" ? "aiff" : "mp3";
// Create filename
const timestamp = Date.now();
const filename = postId
? `tts-${postId}-${timestamp}.${ext}`
: `tts-${timestamp}.${ext}`;
// Upload to Supabase Storage
const { url, path, size } = await uploadAudio(audioBuffer, filename, format);
// If postId provided, update the blog post with audio URL
if (postId) {
const { error: updateError } = await serviceSupabase
.from("blog_messages")
.update({
audio_url: url,
audio_duration: duration,
})
.eq("id", postId);
if (updateError) {
console.error("[TTS] Error updating post with audio:", updateError);
// Don't fail the request, just log the error
}
}
return NextResponse.json({
success: true,
url,
path,
duration,
size,
format,
});
} catch (error) {
console.error("[TTS] Generation error:", error);
return NextResponse.json(
{ error: "Failed to generate TTS audio" },
{ status: 500 }
);
}
}
/**
* GET /api/tts?postId={id}
* Get TTS audio info for a specific blog post
*/
export async function GET(request: Request) {
const { searchParams } = new URL(request.url);
const postId = searchParams.get("postId");
if (!postId) {
return NextResponse.json({ error: "Post ID required" }, { status: 400 });
}
const { data, error } = await serviceSupabase
.from("blog_messages")
.select("id, audio_url, audio_duration")
.eq("id", postId)
.single();
if (error) {
console.error("Error fetching TTS info:", error);
return NextResponse.json({ error: "Failed to fetch TTS info" }, { status: 500 });
}
return NextResponse.json({
hasAudio: !!data?.audio_url,
audioUrl: data?.audio_url,
duration: data?.audio_duration,
});
}

View File

@ -14,7 +14,6 @@ export function AudioPlayer({ url, duration, className = "" }: AudioPlayerProps)
const [currentTime, setCurrentTime] = useState(0); const [currentTime, setCurrentTime] = useState(0);
const [audioDuration, setAudioDuration] = useState(duration || 0); const [audioDuration, setAudioDuration] = useState(duration || 0);
const [volume, setVolume] = useState(1); const [volume, setVolume] = useState(1);
const [isLoaded, setIsLoaded] = useState(false);
useEffect(() => { useEffect(() => {
const audio = audioRef.current; const audio = audioRef.current;
@ -23,7 +22,6 @@ export function AudioPlayer({ url, duration, className = "" }: AudioPlayerProps)
const handleTimeUpdate = () => setCurrentTime(audio.currentTime); const handleTimeUpdate = () => setCurrentTime(audio.currentTime);
const handleLoadedMetadata = () => { const handleLoadedMetadata = () => {
setAudioDuration(audio.duration); setAudioDuration(audio.duration);
setIsLoaded(true);
}; };
const handleEnded = () => setIsPlaying(false); const handleEnded = () => setIsPlaying(false);
const handlePlay = () => setIsPlaying(true); const handlePlay = () => setIsPlaying(true);

12
vercel.json Normal file
View File

@ -0,0 +1,12 @@
{
"crons": [
{
"path": "/api/ingest",
"schedule": "0 6 * * *"
},
{
"path": "/api/generate-digest",
"schedule": "0 6 * * *"
}
]
}