Initial commit: Leopost Full — merge di Leopost, Post Generator e Autopilot OS

- Backend FastAPI con multi-LLM (Claude/OpenAI/Gemini)
- Publishing su Facebook, Instagram, YouTube, TikTok
- Calendario editoriale con awareness levels (PAS, AIDA, BAB...)
- Design system Editorial Fresh (Fraunces + DM Sans)
- Scheduler automatico, gestione commenti AI, affiliate links

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Michele
2026-03-31 17:23:16 +02:00
commit 519a580679
58 changed files with 8348 additions and 0 deletions

View File

@@ -0,0 +1,281 @@
"""Comment management router.
Handles fetching, reviewing, and replying to comments on published posts.
"""
from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.orm import Session
from ..auth import get_current_user
from ..database import get_db
from ..models import Comment, Post, ScheduledPost, SocialAccount, SystemSetting
from ..schemas import CommentAction, CommentResponse
from ..services.llm import get_llm_provider
from ..services.social import get_publisher
router = APIRouter(
prefix="/api/comments",
tags=["comments"],
dependencies=[Depends(get_current_user)],
)
@router.get("/", response_model=list[CommentResponse])
def list_comments(
platform: str | None = Query(None),
reply_status: str | None = Query(None),
scheduled_post_id: int | None = Query(None),
db: Session = Depends(get_db),
):
"""List comments with optional filters."""
query = db.query(Comment)
if platform is not None:
query = query.filter(Comment.platform == platform)
if reply_status is not None:
query = query.filter(Comment.reply_status == reply_status)
if scheduled_post_id is not None:
query = query.filter(Comment.scheduled_post_id == scheduled_post_id)
return query.order_by(Comment.created_at.desc()).all()
@router.get("/pending", response_model=list[CommentResponse])
def list_pending_comments(db: Session = Depends(get_db)):
"""Get only pending comments (reply_status='pending')."""
return (
db.query(Comment)
.filter(Comment.reply_status == "pending")
.order_by(Comment.created_at.desc())
.all()
)
@router.get("/{comment_id}", response_model=CommentResponse)
def get_comment(comment_id: int, db: Session = Depends(get_db)):
"""Get a single comment by ID."""
comment = db.query(Comment).filter(Comment.id == comment_id).first()
if not comment:
raise HTTPException(status_code=404, detail="Comment not found")
return comment
@router.post("/{comment_id}/action", response_model=CommentResponse)
def action_on_comment(
comment_id: int, data: CommentAction, db: Session = Depends(get_db)
):
"""Take action on a comment: approve, edit, or ignore."""
comment = db.query(Comment).filter(Comment.id == comment_id).first()
if not comment:
raise HTTPException(status_code=404, detail="Comment not found")
if data.action == "approve":
comment.approved_reply = comment.ai_suggested_reply
comment.reply_status = "approved"
elif data.action == "edit":
if not data.reply_text:
raise HTTPException(status_code=400, detail="reply_text required for edit action")
comment.approved_reply = data.reply_text
comment.reply_status = "approved"
elif data.action == "ignore":
comment.reply_status = "ignored"
else:
raise HTTPException(status_code=400, detail=f"Unknown action '{data.action}'. Use: approve, edit, ignore")
db.commit()
db.refresh(comment)
return comment
@router.post("/{comment_id}/reply", response_model=CommentResponse)
def reply_to_comment(comment_id: int, db: Session = Depends(get_db)):
"""Send the approved reply via the social platform API."""
comment = db.query(Comment).filter(Comment.id == comment_id).first()
if not comment:
raise HTTPException(status_code=404, detail="Comment not found")
if not comment.approved_reply:
raise HTTPException(status_code=400, detail="No approved reply to send. Use /action first.")
if not comment.external_comment_id:
raise HTTPException(status_code=400, detail="No external comment ID available for reply")
# Find the social account for this platform via the scheduled post
if not comment.scheduled_post_id:
raise HTTPException(status_code=400, detail="Comment is not linked to a scheduled post")
scheduled = (
db.query(ScheduledPost)
.filter(ScheduledPost.id == comment.scheduled_post_id)
.first()
)
if not scheduled:
raise HTTPException(status_code=404, detail="Associated scheduled post not found")
post = db.query(Post).filter(Post.id == scheduled.post_id).first()
if not post:
raise HTTPException(status_code=404, detail="Associated post not found")
account = (
db.query(SocialAccount)
.filter(
SocialAccount.character_id == post.character_id,
SocialAccount.platform == comment.platform,
SocialAccount.is_active == True,
)
.first()
)
if not account:
raise HTTPException(
status_code=400,
detail=f"No active {comment.platform} account found for this character",
)
# Build publisher kwargs
kwargs: dict = {}
if account.platform == "facebook":
kwargs["page_id"] = account.page_id
elif account.platform == "instagram":
kwargs["ig_user_id"] = account.account_id or (account.extra_data or {}).get("ig_user_id")
try:
publisher = get_publisher(account.platform, account.access_token, **kwargs)
success = publisher.reply_to_comment(comment.external_comment_id, comment.approved_reply)
if not success:
raise RuntimeError("Platform returned failure for reply")
comment.reply_status = "replied"
comment.replied_at = datetime.utcnow()
db.commit()
db.refresh(comment)
return comment
except (RuntimeError, ValueError) as e:
raise HTTPException(status_code=502, detail=f"Failed to send reply: {e}")
@router.post("/fetch/{platform}")
def fetch_comments(platform: str, db: Session = Depends(get_db)):
"""Fetch new comments from a platform for all published posts.
Creates Comment records for any new comments not already in the database.
Uses LLM to generate AI-suggested replies for each new comment.
"""
# Get all published scheduled posts for this platform
published_posts = (
db.query(ScheduledPost)
.filter(
ScheduledPost.platform == platform,
ScheduledPost.status == "published",
ScheduledPost.external_post_id != None,
)
.all()
)
if not published_posts:
return {"new_comments": 0, "message": f"No published posts found for {platform}"}
# Get LLM settings for AI reply generation
llm_provider_name = None
llm_api_key = None
llm_model = None
for key in ("llm_provider", "llm_api_key", "llm_model"):
setting = db.query(SystemSetting).filter(SystemSetting.key == key).first()
if setting:
if key == "llm_provider":
llm_provider_name = setting.value
elif key == "llm_api_key":
llm_api_key = setting.value
elif key == "llm_model":
llm_model = setting.value
llm = None
if llm_provider_name and llm_api_key:
try:
llm = get_llm_provider(llm_provider_name, llm_api_key, llm_model)
except ValueError:
pass # LLM not available, skip AI replies
new_comment_count = 0
for scheduled in published_posts:
# Get the post to find the character
post = db.query(Post).filter(Post.id == scheduled.post_id).first()
if not post:
continue
# Find the social account
account = (
db.query(SocialAccount)
.filter(
SocialAccount.character_id == post.character_id,
SocialAccount.platform == platform,
SocialAccount.is_active == True,
)
.first()
)
if not account or not account.access_token:
continue
# Build publisher kwargs
kwargs: dict = {}
if account.platform == "facebook":
kwargs["page_id"] = account.page_id
elif account.platform == "instagram":
kwargs["ig_user_id"] = account.account_id or (account.extra_data or {}).get("ig_user_id")
try:
publisher = get_publisher(account.platform, account.access_token, **kwargs)
comments = publisher.get_comments(scheduled.external_post_id)
except (RuntimeError, ValueError):
continue # Skip this post if API call fails
for ext_comment in comments:
ext_id = ext_comment.get("id", "")
if not ext_id:
continue
# Check if comment already exists
existing = (
db.query(Comment)
.filter(Comment.external_comment_id == ext_id)
.first()
)
if existing:
continue
# Generate AI suggested reply if LLM is available
ai_reply = None
if llm:
try:
system_prompt = (
f"You are managing social media comments for a content creator. "
f"Write a friendly, on-brand reply to this comment. "
f"Keep it concise (1-2 sentences). Be authentic and engaging."
)
prompt = (
f"Comment by {ext_comment.get('author', 'someone')}: "
f"\"{ext_comment.get('text', '')}\"\n\n"
f"Write a reply:"
)
ai_reply = llm.generate(prompt, system=system_prompt)
except RuntimeError:
pass # Skip AI reply if generation fails
# Create comment record
comment = Comment(
scheduled_post_id=scheduled.id,
platform=platform,
external_comment_id=ext_id,
author_name=ext_comment.get("author", "Unknown"),
author_id=ext_comment.get("id", ""),
content=ext_comment.get("text", ""),
ai_suggested_reply=ai_reply,
reply_status="pending",
)
db.add(comment)
new_comment_count += 1
db.commit()
return {"new_comments": new_comment_count, "message": f"Fetched {new_comment_count} new comments from {platform}"}