system-prompts-and-models-o.../personal-brand-engine/agents/social_media/agent.py
VoXc2 4bb2442313
Add Personal Brand Engine - 7 AI Agents Automation System
Complete AI-powered personal brand automation for Sami Assiri.\n\n7 agents: LinkedIn, Email, Social Media, WhatsApp, CV Optimizer, Content Strategist, Opportunity Scout.\nInfra: FastAPI + APScheduler + Docker + Ollama/Groq LLM + GitHub Pages landing page.\n83 files, ~10K lines. Cost: $0-5/month.
2026-03-30 11:45:48 +03:00

278 lines
9.8 KiB
Python

"""Social media agent -- posts to Twitter/X and repurposes content across platforms."""
from __future__ import annotations
import logging
import time
from typing import Any
from sqlalchemy.orm import Session
from agents.base_agent import BaseAgent
from agents.social_media.content_repurposer import (
repurpose_linkedin_to_twitter,
)
from agents.social_media.twitter import (
create_thread,
post_tweet,
)
logger = logging.getLogger(__name__)
# In-memory rate limiter.
_RATE_LIMIT_WINDOW: dict[str, float] = {}
RATE_LIMIT_SECONDS: dict[str, int] = {
"post_twitter": 3600, # 1 hour between tweets
"repurpose_content": 7200, # 2 hours between repurpose runs
}
class SocialMediaAgent(BaseAgent):
"""Autonomous social-media agent for Sami Mohammed Assiri's personal brand.
Currently supports Twitter/X with plans to expand to other platforms.
"""
agent_name: str = "social_media"
def __init__(
self,
config: Any,
llm_client: Any,
db_session: Session,
) -> None:
super().__init__(config, llm_client, db_session)
# ------------------------------------------------------------------
# Rate limiting
# ------------------------------------------------------------------
@staticmethod
def _is_rate_limited(action: str) -> bool:
last = _RATE_LIMIT_WINDOW.get(action)
if last is None:
return False
window = RATE_LIMIT_SECONDS.get(action, 0)
return (time.time() - last) < window
@staticmethod
def _mark_executed(action: str) -> None:
_RATE_LIMIT_WINDOW[action] = time.time()
# ------------------------------------------------------------------
# Task dispatcher
# ------------------------------------------------------------------
async def run(self, task: str, **kwargs: Any) -> dict:
"""Dispatch *task* to the appropriate handler.
Supported tasks:
- ``post_twitter`` -- create and post a tweet
- ``repurpose_content`` -- adapt LinkedIn posts for Twitter
"""
dispatch = {
"post_twitter": self._post_twitter,
"repurpose_content": self._repurpose_content,
}
handler = dispatch.get(task)
if handler is None:
self.log_action(task, details=f"Unknown task: {task}", status="failed")
return {"status": "error", "message": f"Unknown task: {task}"}
if self._is_rate_limited(task):
msg = f"Rate-limited: {task} was run too recently."
logger.warning(msg)
self.log_action(task, details=msg, status="skipped")
return {"status": "skipped", "message": msg}
with self.timer() as t:
try:
result = await handler(**kwargs)
self._mark_executed(task)
self.log_action(task, details=str(result), duration=t.elapsed)
return {"status": "success", "result": result}
except Exception as exc:
logger.exception("Task %s failed", task)
self.log_action(
task,
details=str(exc),
status="failed",
duration=t.elapsed,
)
await self.notify_owner(
f"[Social Media Agent] Task '{task}' failed: {exc}"
)
return {"status": "error", "message": str(exc)}
# ------------------------------------------------------------------
# post_twitter
# ------------------------------------------------------------------
async def _post_twitter(
self,
*,
content: str | None = None,
pillar: str | None = None,
) -> dict:
"""Generate (if needed) and post a tweet.
Parameters
----------
content:
Explicit tweet text. If not provided, the LLM generates one
based on the brand profile and content strategy.
pillar:
Optional content pillar to guide generation (e.g.
``"airport_security"``, ``"engineering_tips"``).
"""
if content is None:
content = await self._generate_tweet(pillar=pillar)
api_keys = self._get_twitter_keys()
result = post_tweet(api_keys, content)
logger.info("Posted tweet: %s", content[:80])
return {"tweet": content, "api_response": result}
async def _generate_tweet(self, *, pillar: str | None = None) -> str:
"""Use the LLM to generate a tweet aligned with the brand."""
brand_profile = self.get_brand_profile()
content_strategy = self.get_content_strategy()
pillar_hint = ""
if pillar:
pillars = content_strategy.get("content_pillars", {})
pillar_data = pillars.get(pillar, {})
if pillar_data:
pillar_hint = (
f"\nFocus on this content pillar: {pillar}\n"
f"Description: {pillar_data.get('description', '')}\n"
f"Topics: {', '.join(pillar_data.get('topics', []))}"
)
name = brand_profile.get("name", "Sami Mohammed Assiri")
title = brand_profile.get("title", "Field Services Engineer")
company = brand_profile.get("company", "METCO (Smiths Detection)")
messages = [
{
"role": "system",
"content": (
f"You are a Twitter/X content creator for {name}, "
f"{title} at {company} in Riyadh, Saudi Arabia. "
"Create engaging, professional tweets about airport security, "
"engineering, and technology. Keep tweets under 280 characters. "
"Use 1-3 relevant hashtags. Be authentic and insightful."
f"{pillar_hint}"
),
},
{
"role": "user",
"content": "Write a single engaging tweet for my professional audience.",
},
]
response_text = await self._call_llm(messages)
# Strip any surrounding quotes the LLM might add
return response_text.strip().strip('"').strip("'")
# ------------------------------------------------------------------
# repurpose_content
# ------------------------------------------------------------------
async def _repurpose_content(
self,
*,
linkedin_post: str | None = None,
post_as_thread: bool = True,
) -> dict:
"""Take a LinkedIn post and adapt it for Twitter.
Parameters
----------
linkedin_post:
The full text of the LinkedIn post. Must be provided.
post_as_thread:
If ``True`` and the repurposed content has multiple tweets,
post them as a thread.
"""
if not linkedin_post:
return {"error": "No linkedin_post content provided."}
tweets = await repurpose_linkedin_to_twitter(
llm_client=self.llm,
linkedin_post=linkedin_post,
)
if not tweets:
return {"error": "Repurposing produced no tweets."}
api_keys = self._get_twitter_keys()
if len(tweets) == 1 or not post_as_thread:
result = post_tweet(api_keys, tweets[0])
return {"tweets": tweets, "posted": 1, "api_response": result}
else:
results = create_thread(api_keys, tweets)
return {"tweets": tweets, "posted": len(tweets), "api_responses": results}
# ------------------------------------------------------------------
# Helpers
# ------------------------------------------------------------------
def _get_twitter_keys(self) -> dict[str, str]:
"""Extract Twitter API credentials from config."""
return {
"api_key": self.config.twitter_api_key,
"api_secret": self.config.twitter_api_secret,
"access_token": self.config.twitter_access_token,
"access_secret": self.config.twitter_access_secret,
"bearer_token": self.config.twitter_bearer_token,
}
async def _call_llm(self, messages: list[dict[str, str]]) -> str:
"""Invoke the LLM client, handling different API shapes."""
import asyncio
import inspect
# OpenAI / Groq compatible
if hasattr(self.llm, "chat") and hasattr(self.llm.chat, "completions"):
func = self.llm.chat.completions.create
if inspect.iscoroutinefunction(func):
resp = await func(messages=messages, max_tokens=300, temperature=0.8)
else:
loop = asyncio.get_event_loop()
resp = await loop.run_in_executor(
None,
lambda: func(messages=messages, max_tokens=300, temperature=0.8),
)
return resp.choices[0].message.content
# Ollama-style
if hasattr(self.llm, "chat"):
func = self.llm.chat
if inspect.iscoroutinefunction(func):
resp = await func(messages=messages)
else:
loop = asyncio.get_event_loop()
resp = await loop.run_in_executor(
None, lambda: func(messages=messages)
)
if isinstance(resp, dict):
return resp.get("message", {}).get("content", "")
return str(resp)
# Generic callable
if callable(self.llm):
if inspect.iscoroutinefunction(self.llm):
resp = await self.llm(messages=messages)
else:
loop = asyncio.get_event_loop()
resp = await loop.run_in_executor(
None, lambda: self.llm(messages=messages)
)
return str(resp)
raise TypeError(f"Unsupported LLM client type: {type(self.llm)}")