mirror of
https://github.com/x1xhlol/system-prompts-and-models-of-ai-tools.git
synced 2026-06-18 07:19:35 +00:00
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.
278 lines
9.8 KiB
Python
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)}")
|