export default { async fetch(request, env) { const url = new URL(request.url); // ── Image proxy (/proxy-image?url=...) ────────────────── if (url.pathname === "/proxy-image") { const imgUrl = url.searchParams.get("url"); if (!imgUrl) return new Response("Missing url param", { status: 400 }); try { const imgRes = await fetch(imgUrl, { headers: { "User-Agent": "Mozilla/5.0 (compatible; Googlebot/2.1)", "Referer": new URL(imgUrl).origin, } }); const contentType = imgRes.headers.get("content-type") || "image/jpeg"; return new Response(imgRes.body, { status: imgRes.status, headers: { "Content-Type": contentType, "Access-Control-Allow-Origin": "*", "Cache-Control": "public, max-age=86400", } }); } catch (e) { return new Response("Image fetch failed: " + e.message, { status: 502 }); } } // ── Post to Facebook (/post-to-facebook) ───────────────── // Receives the finished graphic as a binary blob (no public image // hosting needed) + caption from the browser, forwards it to the // Facebook Graph API using a Page Access Token kept server-side as a // Worker secret (never exposed to the browser). if (url.pathname === "/post-to-facebook") { if (request.method === "OPTIONS") return corsResponse(null, 204); if (request.method !== "POST") return corsResponse(JSON.stringify({ error: "Method not allowed" }), 405); const pageId = env.FB_PAGE_ID; const accessToken = env.FB_PAGE_ACCESS_TOKEN; if (!pageId || !accessToken) { return corsResponse(JSON.stringify({ error: "Facebook not configured on the Worker — set FB_PAGE_ID and FB_PAGE_ACCESS_TOKEN secrets first." }), 500); } try { const incomingForm = await request.formData(); const imageBlob = incomingForm.get("image"); const caption = incomingForm.get("caption") || ""; const scheduledTime = incomingForm.get("scheduledTime"); // unix seconds, optional — presence means "schedule instead of post now" if (!imageBlob) return corsResponse(JSON.stringify({ error: "Missing image" }), 400); // ── Immediate post: single call, published photo (unchanged) ── if (!scheduledTime) { const fbForm = new FormData(); fbForm.append("source", imageBlob, "post.jpg"); fbForm.append("caption", caption); fbForm.append("access_token", accessToken); const fbRes = await fetch(`https://graph.facebook.com/v21.0/${pageId}/photos`, { method: "POST", body: fbForm, }); const fbData = await fbRes.json(); if (fbData.error) { return corsResponse(JSON.stringify({ error: fbData.error.message || "Facebook API error" }), 502); } return corsResponse(JSON.stringify({ ok: true, postId: fbData.post_id || fbData.id }), 200); } // ── Scheduled post: two-step flow ────────────────────── // A scheduled "/photos" object with scheduled_publish_time is NOT // the same thing as a scheduled Feed post — it doesn't show up in // Meta Business Suite's Content → Posts and reels → Scheduled tab. // The correct approach (what Business Suite itself does under the // hood): 1) upload the photo as unpublished (no scheduling on this // call), get back a photo ID, then 2) create a real Feed post via // /{page-id}/feed, attach that photo via attached_media, and put // published=false + scheduled_publish_time on THIS call instead. const ts = parseInt(scheduledTime, 10); const nowSec = Math.floor(Date.now() / 1000); const minAllowed = nowSec + 10 * 60; // Facebook requires at least 10 minutes out const maxAllowed = nowSec + 6 * 30 * 24 * 60 * 60; // ~6 months out if (!ts || ts < minAllowed || ts > maxAllowed) { return corsResponse(JSON.stringify({ error: "Scheduled time must be at least 10 minutes and at most ~6 months from now." }), 400); } // Step 1: upload the photo, unpublished, not attached to the feed yet. const uploadForm = new FormData(); uploadForm.append("source", imageBlob, "post.jpg"); uploadForm.append("published", "false"); uploadForm.append("access_token", accessToken); const uploadRes = await fetch(`https://graph.facebook.com/v21.0/${pageId}/photos`, { method: "POST", body: uploadForm, }); const uploadData = await uploadRes.json(); if (uploadData.error) { return corsResponse(JSON.stringify({ error: "Photo upload step failed: " + (uploadData.error.message || "unknown error") }), 502); } const photoId = uploadData.id; if (!photoId) { return corsResponse(JSON.stringify({ error: "Photo upload succeeded but returned no ID." }), 502); } // Step 2: create the actual scheduled Feed post, attaching that photo. const feedRes = await fetch(`https://graph.facebook.com/v21.0/${pageId}/feed`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ message: caption, attached_media: [{ media_fbid: photoId }], published: false, scheduled_publish_time: ts, access_token: accessToken, }), }); const feedData = await feedRes.json(); if (feedData.error) { return corsResponse(JSON.stringify({ error: "Scheduling step failed: " + (feedData.error.message || "unknown error") }), 502); } return corsResponse(JSON.stringify({ ok: true, postId: feedData.id }), 200); } catch (e) { return corsResponse(JSON.stringify({ error: "Post failed: " + e.message }), 500); } } // ── Cancel a scheduled Facebook post (/cancel-scheduled-post) ──── // Deletes an unpublished/scheduled post before its publish time fires. // Facebook allows DELETE on the post/photo ID as long as it hasn't // gone live yet. if (url.pathname === "/cancel-scheduled-post") { if (request.method === "OPTIONS") return corsResponse(null, 204); if (request.method !== "POST") return corsResponse(JSON.stringify({ error: "Method not allowed" }), 405); const accessToken = env.FB_PAGE_ACCESS_TOKEN; if (!accessToken) { return corsResponse(JSON.stringify({ error: "Facebook not configured on the Worker." }), 500); } try { const { postId } = await request.json(); if (!postId) return corsResponse(JSON.stringify({ error: "Missing postId" }), 400); const fbRes = await fetch( `https://graph.facebook.com/v21.0/${postId}?access_token=${encodeURIComponent(accessToken)}`, { method: "DELETE" } ); const fbData = await fbRes.json(); if (fbData.error) { return corsResponse(JSON.stringify({ error: fbData.error.message || "Facebook API error" }), 502); } return corsResponse(JSON.stringify({ ok: true }), 200); } catch (e) { return corsResponse(JSON.stringify({ error: "Cancel failed: " + e.message }), 500); } } // ── CORS preflight ─────────────────────────────────────── if (request.method === "OPTIONS") return corsResponse(null, 204); if (request.method !== "POST") return corsResponse(JSON.stringify({ error: "Method not allowed" }), 405); let body; try { body = await request.json(); } catch { return corsResponse(JSON.stringify({ error: "Invalid JSON body" }), 400); } const { prompt, systemPrompt, searchQuery } = body; // ── Step 1: Search with Tavily ─────────────────────────── // searchQuery is sent by the frontend — it's topic-specific when the // user generates a custom draft, or a broad hardware+gaming query for // the main generate button. Falls back to a safe default if missing. const tavilyQuery = searchQuery || "latest PC hardware GPU CPU gaming news updates this month"; let searchContext = ""; try { const tavilyRes = await fetch("https://api.tavily.com/search", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ api_key: env.TAVILY_API_KEY, query: tavilyQuery, search_depth: "advanced", max_results: 8, include_images: true, include_answer: false, }) }); const tavilyData = await tavilyRes.json(); const results = tavilyData.results || []; searchContext = results.map(r => `TITLE: ${r.title}\nURL: ${r.url}\nIMAGE: ${r.image || ""}\nSNIPPET: ${r.content?.slice(0, 400) || ""}` ).join("\n\n---\n\n"); } catch (e) { searchContext = "(Search unavailable — use your training knowledge for recent PC hardware news)"; } // ── Step 2: Generate captions with Groq ───────────────── const fullPrompt = `Here are today's PC hardware news search results:\n\n${searchContext}\n\n---\n\n${prompt}`; const groqRes = await fetch("https://api.groq.com/openai/v1/chat/completions", { method: "POST", headers: { "Content-Type": "application/json", "Authorization": `Bearer ${env.GROQ_API_KEY}`, }, body: JSON.stringify({ model: "llama-3.3-70b-versatile", max_tokens: 4096, temperature: 0.7, messages: [ { role: "system", content: systemPrompt }, { role: "user", content: fullPrompt } ] }) }); const groqData = await groqRes.json(); const text = groqData.choices?.[0]?.message?.content || ""; if (!text) return corsResponse(JSON.stringify({ error: "Groq returned no text", raw: groqData }), 500); return corsResponse(JSON.stringify({ text }), 200); }, }; function corsResponse(body, status = 200) { return new Response(body, { status, headers: { "Content-Type": "application/json", "Access-Control-Allow-Origin": "*", "Access-Control-Allow-Methods": "POST, OPTIONS, GET", "Access-Control-Allow-Headers": "Content-Type", }, }); }