Compare commits
12 Commits
4b15b552d6
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| c1960932a2 | |||
| 7f8a2d226c | |||
| 684e20ff84 | |||
| 550d958025 | |||
| dd8b4e4c6e | |||
| c7cdebcd5d | |||
| 0163767171 | |||
| 2d3c559fcb | |||
| 5ba5cae98f | |||
| 88450fd8b6 | |||
| 91d9fa3a21 | |||
| 2885a23544 |
@@ -1,3 +1,4 @@
|
||||
# syntax=docker/dockerfile:1
|
||||
# LinkedIn Post Creation System - Docker Image
|
||||
FROM python:3.11-slim
|
||||
|
||||
@@ -17,8 +18,9 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
# Copy requirements first for better caching
|
||||
COPY requirements.txt .
|
||||
|
||||
# Install Python dependencies
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
# Install Python dependencies (CPU-only torch to avoid downloading 2GB CUDA binaries)
|
||||
RUN --mount=type=cache,target=/root/.cache/pip \
|
||||
pip install --extra-index-url https://download.pytorch.org/whl/cpu -r requirements.txt
|
||||
|
||||
# Copy application code
|
||||
COPY . .
|
||||
|
||||
@@ -20,6 +20,8 @@ services:
|
||||
image: redis:7-alpine
|
||||
restart: unless-stopped
|
||||
command: redis-server --maxmemory 128mb --maxmemory-policy allkeys-lru --save ""
|
||||
labels:
|
||||
- traefik.enable=false
|
||||
healthcheck:
|
||||
test: ["CMD", "redis-cli", "ping"]
|
||||
interval: 10s
|
||||
@@ -30,6 +32,8 @@ services:
|
||||
build: .
|
||||
restart: unless-stopped
|
||||
command: python -m src.services.scheduler_runner
|
||||
labels:
|
||||
- traefik.enable=false
|
||||
environment:
|
||||
- PYTHONPATH=/app
|
||||
- SCHEDULER_ENABLED=true
|
||||
|
||||
@@ -69,6 +69,13 @@ class Settings(BaseSettings):
|
||||
redis_url: str = "redis://redis:6379/0"
|
||||
scheduler_enabled: bool = False # True only on dedicated scheduler container
|
||||
|
||||
# Telegram Bot (experimental)
|
||||
telegram_enabled: bool = False
|
||||
telegram_bot_token: str = ""
|
||||
telegram_bot_username: str = "" # e.g. "MyLinkedInBot" (without @)
|
||||
telegram_webhook_secret: str = "" # Random string to validate incoming webhooks
|
||||
telegram_webhook_url: str = "" # Base URL of the app, e.g. https://app.example.com
|
||||
|
||||
model_config = SettingsConfigDict(
|
||||
env_file=".env",
|
||||
env_file_encoding="utf-8",
|
||||
|
||||
@@ -817,6 +817,63 @@ class DatabaseClient:
|
||||
await cache.invalidate_linkedin_account(user_id)
|
||||
logger.info(f"Deleted LinkedIn account: {account_id}")
|
||||
|
||||
# ==================== TELEGRAM ACCOUNTS ====================
|
||||
|
||||
async def get_telegram_account(self, user_id: UUID) -> Optional['TelegramAccount']:
|
||||
"""Get Telegram account for user."""
|
||||
from src.database.models import TelegramAccount
|
||||
result = await asyncio.to_thread(
|
||||
lambda: self.client.table("telegram_accounts").select("*")
|
||||
.eq("user_id", str(user_id)).eq("is_active", True).execute()
|
||||
)
|
||||
if result.data:
|
||||
return TelegramAccount(**result.data[0])
|
||||
return None
|
||||
|
||||
async def get_telegram_account_by_chat_id(self, chat_id: str) -> Optional['TelegramAccount']:
|
||||
"""Get Telegram account by chat_id."""
|
||||
from src.database.models import TelegramAccount
|
||||
result = await asyncio.to_thread(
|
||||
lambda: self.client.table("telegram_accounts").select("*")
|
||||
.eq("telegram_chat_id", chat_id).eq("is_active", True).execute()
|
||||
)
|
||||
if result.data:
|
||||
return TelegramAccount(**result.data[0])
|
||||
return None
|
||||
|
||||
async def save_telegram_account(self, account: 'TelegramAccount') -> 'TelegramAccount':
|
||||
"""Create or update a Telegram account connection."""
|
||||
from src.database.models import TelegramAccount
|
||||
data = account.model_dump(exclude={'id', 'created_at', 'updated_at'}, exclude_none=True)
|
||||
data['user_id'] = str(data['user_id'])
|
||||
|
||||
existing = await asyncio.to_thread(
|
||||
lambda: self.client.table("telegram_accounts").select("id")
|
||||
.eq("user_id", str(account.user_id)).execute()
|
||||
)
|
||||
|
||||
if existing.data:
|
||||
result = await asyncio.to_thread(
|
||||
lambda: self.client.table("telegram_accounts").update(data)
|
||||
.eq("user_id", str(account.user_id)).execute()
|
||||
)
|
||||
else:
|
||||
result = await asyncio.to_thread(
|
||||
lambda: self.client.table("telegram_accounts").insert(data).execute()
|
||||
)
|
||||
|
||||
logger.info(f"Saved Telegram account for user: {account.user_id}")
|
||||
return TelegramAccount(**result.data[0])
|
||||
|
||||
async def delete_telegram_account(self, user_id: UUID) -> bool:
|
||||
"""Delete Telegram account connection for user."""
|
||||
await asyncio.to_thread(
|
||||
lambda: self.client.table("telegram_accounts").delete()
|
||||
.eq("user_id", str(user_id)).execute()
|
||||
)
|
||||
logger.info(f"Deleted Telegram account for user: {user_id}")
|
||||
return True
|
||||
|
||||
# ==================== USERS ====================
|
||||
|
||||
async def get_user(self, user_id: UUID) -> Optional[User]:
|
||||
|
||||
@@ -103,6 +103,22 @@ class LinkedInAccount(DBModel):
|
||||
last_error_at: Optional[datetime] = None
|
||||
|
||||
|
||||
class TelegramAccount(DBModel):
|
||||
"""Telegram account connection for bot access."""
|
||||
id: Optional[UUID] = None
|
||||
user_id: UUID
|
||||
created_at: Optional[datetime] = None
|
||||
updated_at: Optional[datetime] = None
|
||||
telegram_user_id: str
|
||||
telegram_username: Optional[str] = None
|
||||
telegram_first_name: Optional[str] = None
|
||||
telegram_chat_id: str
|
||||
is_active: bool = True
|
||||
last_used_at: Optional[datetime] = None
|
||||
last_error: Optional[str] = None
|
||||
last_error_at: Optional[datetime] = None
|
||||
|
||||
|
||||
class User(DBModel):
|
||||
"""User model - combines auth.users data with profile data.
|
||||
|
||||
|
||||
469
src/services/telegram_service.py
Normal file
469
src/services/telegram_service.py
Normal file
@@ -0,0 +1,469 @@
|
||||
"""Telegram bot service for LinkedIn post creation via chat."""
|
||||
import asyncio
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional
|
||||
from uuid import UUID
|
||||
|
||||
import httpx
|
||||
from loguru import logger
|
||||
|
||||
from src.config import settings
|
||||
from src.services.redis_client import get_redis
|
||||
|
||||
# Conversation state TTL: 24 hours
|
||||
CONV_TTL = 86400
|
||||
# Rate limit: max requests per hour per user
|
||||
RATE_LIMIT_MAX = 10
|
||||
RATE_LIMIT_TTL = 3600
|
||||
|
||||
|
||||
class TelegramService:
|
||||
"""Handles Telegram bot interactions for LinkedIn post creation."""
|
||||
|
||||
def __init__(self):
|
||||
self._base_url = f"https://api.telegram.org/bot{settings.telegram_bot_token}"
|
||||
|
||||
# ==================== TELEGRAM API HELPERS ====================
|
||||
|
||||
async def send_message(self, chat_id: str, text: str, reply_markup: Optional[dict] = None) -> dict:
|
||||
"""Send a text message to a Telegram chat."""
|
||||
payload: dict = {"chat_id": chat_id, "text": text, "parse_mode": "HTML"}
|
||||
if reply_markup:
|
||||
payload["reply_markup"] = reply_markup
|
||||
async with httpx.AsyncClient(timeout=15.0) as client:
|
||||
resp = await client.post(f"{self._base_url}/sendMessage", json=payload)
|
||||
return resp.json()
|
||||
|
||||
async def answer_callback_query(self, callback_query_id: str, text: str = "") -> None:
|
||||
"""Answer a callback query to dismiss the loading indicator."""
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
await client.post(f"{self._base_url}/answerCallbackQuery", json={
|
||||
"callback_query_id": callback_query_id,
|
||||
"text": text
|
||||
})
|
||||
|
||||
async def edit_message(self, chat_id: str, message_id: int, text: str, reply_markup: Optional[dict] = None) -> dict:
|
||||
"""Edit an existing message."""
|
||||
payload: dict = {"chat_id": chat_id, "message_id": message_id, "text": text, "parse_mode": "HTML"}
|
||||
if reply_markup:
|
||||
payload["reply_markup"] = reply_markup
|
||||
async with httpx.AsyncClient(timeout=15.0) as client:
|
||||
resp = await client.post(f"{self._base_url}/editMessageText", json=payload)
|
||||
return resp.json()
|
||||
|
||||
async def register_webhook(self, url: str, secret: str) -> None:
|
||||
"""Register webhook URL with Telegram."""
|
||||
async with httpx.AsyncClient(timeout=15.0) as client:
|
||||
resp = await client.post(f"{self._base_url}/setWebhook", json={
|
||||
"url": url,
|
||||
"secret_token": secret,
|
||||
"allowed_updates": ["message", "callback_query"]
|
||||
})
|
||||
data = resp.json()
|
||||
if data.get("ok"):
|
||||
logger.info(f"Telegram webhook registered: {url}")
|
||||
else:
|
||||
logger.error(f"Failed to register Telegram webhook: {data}")
|
||||
|
||||
# ==================== CONVERSATION STATE ====================
|
||||
|
||||
def _conv_key(self, chat_id: str) -> str:
|
||||
return f"telegram_conv:{chat_id}"
|
||||
|
||||
async def _get_conv(self, chat_id: str) -> dict:
|
||||
"""Get conversation state from Redis."""
|
||||
try:
|
||||
redis = await get_redis()
|
||||
raw = await redis.get(self._conv_key(chat_id))
|
||||
if raw:
|
||||
import json
|
||||
return json.loads(raw)
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to get telegram conv state: {e}")
|
||||
return {"state": "idle"}
|
||||
|
||||
async def _set_conv(self, chat_id: str, data: dict) -> None:
|
||||
"""Save conversation state to Redis with 24h TTL."""
|
||||
try:
|
||||
redis = await get_redis()
|
||||
import json
|
||||
await redis.setex(self._conv_key(chat_id), CONV_TTL, json.dumps(data))
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to set telegram conv state: {e}")
|
||||
|
||||
async def _clear_conv(self, chat_id: str) -> None:
|
||||
"""Delete conversation state from Redis."""
|
||||
try:
|
||||
redis = await get_redis()
|
||||
await redis.delete(self._conv_key(chat_id))
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to clear telegram conv state: {e}")
|
||||
|
||||
# ==================== RATE LIMITING ====================
|
||||
|
||||
async def _check_rate_limit(self, user_id: str) -> bool:
|
||||
"""Return True if request is allowed, False if rate limit exceeded."""
|
||||
try:
|
||||
redis = await get_redis()
|
||||
now = datetime.now(timezone.utc)
|
||||
key = f"telegram_rate:{user_id}:{now.strftime('%Y%m%d%H')}"
|
||||
count = await redis.incr(key)
|
||||
if count == 1:
|
||||
await redis.expire(key, RATE_LIMIT_TTL)
|
||||
return count <= RATE_LIMIT_MAX
|
||||
except Exception as e:
|
||||
logger.warning(f"Rate limit check failed (allowing): {e}")
|
||||
return True
|
||||
|
||||
# ==================== KEYBOARD BUILDERS ====================
|
||||
|
||||
def _post_type_keyboard(self, post_types: list) -> dict:
|
||||
"""Build inline keyboard for post type selection."""
|
||||
buttons = []
|
||||
for pt in post_types:
|
||||
buttons.append([{"text": pt.name, "callback_data": f"posttype:{pt.id}"}])
|
||||
return {"inline_keyboard": buttons}
|
||||
|
||||
def _action_keyboard(self) -> dict:
|
||||
"""Build inline keyboard for post actions."""
|
||||
return {"inline_keyboard": [[
|
||||
{"text": "✏️ Überarbeiten", "callback_data": "revise"},
|
||||
{"text": "🔄 Neuer Post", "callback_data": "newpost"}
|
||||
]]}
|
||||
|
||||
# ==================== MAIN DISPATCHER ====================
|
||||
|
||||
async def handle_update(self, update: dict, db) -> None:
|
||||
"""Route incoming Telegram update to the right handler."""
|
||||
try:
|
||||
if "message" in update:
|
||||
msg = update["message"]
|
||||
chat_id = str(msg["chat"]["id"])
|
||||
text = msg.get("text", "")
|
||||
await self._handle_message(chat_id, text, db)
|
||||
|
||||
elif "callback_query" in update:
|
||||
cq = update["callback_query"]
|
||||
chat_id = str(cq["message"]["chat"]["id"])
|
||||
cb_id = cq["id"]
|
||||
data = cq.get("data", "")
|
||||
message_id = cq["message"]["message_id"]
|
||||
await self.answer_callback_query(cb_id)
|
||||
await self._handle_callback_query(chat_id, cb_id, data, message_id, db)
|
||||
|
||||
except Exception as e:
|
||||
logger.exception(f"Error handling Telegram update: {e}")
|
||||
|
||||
# ==================== MESSAGE HANDLER ====================
|
||||
|
||||
async def _handle_message(self, chat_id: str, text: str, db) -> None:
|
||||
"""Handle incoming text messages."""
|
||||
# Look up linked account
|
||||
tg_account = await db.get_telegram_account_by_chat_id(chat_id)
|
||||
|
||||
# Handle /start {token} — account linking flow
|
||||
if text.startswith("/start"):
|
||||
parts = text.split(maxsplit=1)
|
||||
token = parts[1].strip() if len(parts) > 1 else ""
|
||||
await self._handle_start(chat_id, token, db)
|
||||
return
|
||||
|
||||
# All other messages require a linked account
|
||||
if not tg_account:
|
||||
await self.send_message(
|
||||
chat_id,
|
||||
"Dein Telegram-Konto ist noch nicht verknüpft.\n"
|
||||
"Öffne die App unter <b>Einstellungen → Telegram verbinden</b> und folge den Anweisungen."
|
||||
)
|
||||
return
|
||||
|
||||
user_id = str(tg_account.user_id)
|
||||
|
||||
# Rate limiting
|
||||
if not await self._check_rate_limit(user_id):
|
||||
await self.send_message(chat_id, "⚠️ Zu viele Anfragen. Bitte warte eine Stunde und versuche es erneut.")
|
||||
return
|
||||
|
||||
conv = await self._get_conv(chat_id)
|
||||
state = conv.get("state", "idle")
|
||||
|
||||
if state == "waiting_feedback":
|
||||
await self._handle_feedback(chat_id, user_id, text, conv, db)
|
||||
else:
|
||||
# Treat any other text as a new topic
|
||||
await self._handle_new_topic(chat_id, user_id, text, db)
|
||||
|
||||
# ==================== START / ACCOUNT LINKING ====================
|
||||
|
||||
async def _handle_start(self, chat_id: str, token: str, db) -> None:
|
||||
"""Handle /start command — link account if token provided."""
|
||||
if not token:
|
||||
tg_account = await db.get_telegram_account_by_chat_id(chat_id)
|
||||
if tg_account:
|
||||
await self.send_message(chat_id, "Du bist bereits verbunden! Schreib mir ein Thema für deinen nächsten LinkedIn-Post.")
|
||||
else:
|
||||
await self.send_message(
|
||||
chat_id,
|
||||
"Willkommen! 👋\n\nUm diesen Bot zu nutzen, verknüpfe zuerst dein Konto in der App unter "
|
||||
"<b>Einstellungen → Telegram verbinden</b>."
|
||||
)
|
||||
return
|
||||
|
||||
# Look up one-time token in Redis
|
||||
try:
|
||||
redis = await get_redis()
|
||||
link_key = f"telegram_link:{token}"
|
||||
raw_user_id = await redis.get(link_key)
|
||||
if not raw_user_id:
|
||||
await self.send_message(chat_id, "❌ Dieser Link ist ungültig oder abgelaufen. Bitte erstelle einen neuen Link in der App.")
|
||||
return
|
||||
|
||||
user_id_str = raw_user_id.decode() if isinstance(raw_user_id, bytes) else raw_user_id
|
||||
await redis.delete(link_key)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to look up telegram link token: {e}")
|
||||
await self.send_message(chat_id, "❌ Ein Fehler ist aufgetreten. Bitte versuche es erneut.")
|
||||
return
|
||||
|
||||
# Save or update the telegram account
|
||||
from src.database.models import TelegramAccount
|
||||
account = TelegramAccount(
|
||||
user_id=UUID(user_id_str),
|
||||
telegram_chat_id=chat_id,
|
||||
telegram_user_id=chat_id, # chat_id is sufficient as unique identifier
|
||||
is_active=True
|
||||
)
|
||||
await db.save_telegram_account(account)
|
||||
await self._clear_conv(chat_id)
|
||||
|
||||
await self.send_message(
|
||||
chat_id,
|
||||
"✅ <b>Verbunden!</b>\n\nSchreib mir ein Thema für deinen nächsten LinkedIn-Post und ich erstelle ihn für dich."
|
||||
)
|
||||
|
||||
# ==================== TOPIC HANDLING ====================
|
||||
|
||||
async def _handle_new_topic(self, chat_id: str, user_id: str, topic_text: str, db) -> None:
|
||||
"""Handle a new post topic — show post type selection."""
|
||||
post_types = await db.get_post_types(UUID(user_id))
|
||||
active_types = [pt for pt in post_types if pt.is_active]
|
||||
|
||||
if not active_types:
|
||||
await self.send_message(chat_id, "⚠️ Keine Post-Typen gefunden. Bitte richte zuerst deinen Account in der App ein.")
|
||||
return
|
||||
|
||||
conv = {
|
||||
"state": "waiting_post_type",
|
||||
"user_id": user_id,
|
||||
"topic": topic_text
|
||||
}
|
||||
await self._set_conv(chat_id, conv)
|
||||
|
||||
await self.send_message(
|
||||
chat_id,
|
||||
f"📝 Thema: <b>{topic_text}</b>\n\nWähle einen Post-Typ:",
|
||||
reply_markup=self._post_type_keyboard(active_types)
|
||||
)
|
||||
|
||||
# ==================== TOKEN LIMIT CHECK ====================
|
||||
|
||||
async def _check_token_limit(self, user_id: str, db) -> tuple[bool, str]:
|
||||
"""Check company token limit for the user. Returns (can_proceed, error_msg)."""
|
||||
try:
|
||||
profile = await db.get_profile(UUID(user_id))
|
||||
if profile and profile.company_id:
|
||||
can_proceed, error_msg, _, _ = await db.check_company_token_limit(profile.company_id)
|
||||
return can_proceed, error_msg
|
||||
except Exception as e:
|
||||
logger.warning(f"Token limit check failed (allowing): {e}")
|
||||
return True, ""
|
||||
|
||||
# ==================== FEEDBACK HANDLING ====================
|
||||
|
||||
async def _handle_feedback(self, chat_id: str, user_id: str, feedback: str, conv: dict, db) -> None:
|
||||
"""Handle user feedback to revise a post."""
|
||||
post_id = conv.get("post_id")
|
||||
post_content = conv.get("post_content", "")
|
||||
|
||||
if not post_content:
|
||||
await self.send_message(chat_id, "❌ Kein Post gefunden. Bitte starte von vorne mit einem neuen Thema.")
|
||||
await self._clear_conv(chat_id)
|
||||
return
|
||||
|
||||
# Check token limit before calling the LLM
|
||||
can_proceed, limit_msg = await self._check_token_limit(user_id, db)
|
||||
if not can_proceed:
|
||||
await self.send_message(chat_id, f"⚠️ {limit_msg}")
|
||||
return
|
||||
|
||||
await self.send_message(chat_id, "⏳ Überarbeite deinen Post...")
|
||||
|
||||
try:
|
||||
from src.orchestrator import orchestrator
|
||||
improved = await orchestrator.apply_suggestion_to_post(
|
||||
user_id=UUID(user_id),
|
||||
post_content=post_content,
|
||||
suggestion=feedback
|
||||
)
|
||||
|
||||
# Update post in DB: append new version, increment iteration counter
|
||||
if post_id:
|
||||
existing = await db.get_generated_post(UUID(post_id))
|
||||
if existing:
|
||||
new_versions = list(existing.writer_versions) + [improved]
|
||||
await db.update_generated_post(UUID(post_id), {
|
||||
"post_content": improved,
|
||||
"writer_versions": new_versions,
|
||||
"iterations": existing.iterations + 1,
|
||||
})
|
||||
|
||||
# Update conversation state
|
||||
conv["post_content"] = improved
|
||||
conv["state"] = "waiting_feedback"
|
||||
await self._set_conv(chat_id, conv)
|
||||
|
||||
await self.send_message(
|
||||
chat_id,
|
||||
f"✨ <b>Überarbeiteter Post:</b>\n\n{improved}",
|
||||
reply_markup=self._action_keyboard()
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to apply feedback for user {user_id}: {e}")
|
||||
await self.send_message(chat_id, "❌ Fehler beim Überarbeiten. Bitte versuche es erneut.")
|
||||
|
||||
# ==================== CALLBACK HANDLER ====================
|
||||
|
||||
async def _handle_callback_query(self, chat_id: str, cb_id: str, data: str, message_id: int, db) -> None:
|
||||
"""Handle inline keyboard button presses."""
|
||||
tg_account = await db.get_telegram_account_by_chat_id(chat_id)
|
||||
if not tg_account:
|
||||
return
|
||||
|
||||
user_id = str(tg_account.user_id)
|
||||
conv = await self._get_conv(chat_id)
|
||||
|
||||
if data.startswith("posttype:"):
|
||||
post_type_id_str = data.split(":", 1)[1]
|
||||
await self._handle_post_type_selected(chat_id, user_id, post_type_id_str, conv, message_id, db)
|
||||
|
||||
elif data == "revise":
|
||||
conv["state"] = "waiting_feedback"
|
||||
await self._set_conv(chat_id, conv)
|
||||
await self.send_message(chat_id, "✏️ Alles klar, was soll ich verbessern?")
|
||||
|
||||
elif data == "newpost":
|
||||
await self._clear_conv(chat_id)
|
||||
await self.send_message(chat_id, "🔄 Alles klar! Schreib mir dein neues Thema.")
|
||||
|
||||
async def _handle_post_type_selected(
|
||||
self, chat_id: str, user_id: str, post_type_id_str: str, conv: dict, message_id: int, db
|
||||
) -> None:
|
||||
"""Generate a post after the user selects a post type (Writer-only, no critic loop)."""
|
||||
topic_text = conv.get("topic", "")
|
||||
if not topic_text:
|
||||
await self.send_message(chat_id, "❌ Kein Thema gefunden. Bitte starte von vorne.")
|
||||
await self._clear_conv(chat_id)
|
||||
return
|
||||
|
||||
# Check token limit before calling the LLM
|
||||
can_proceed, limit_msg = await self._check_token_limit(user_id, db)
|
||||
if not can_proceed:
|
||||
await self.edit_message(chat_id, message_id, f"⚠️ {limit_msg}")
|
||||
await self._clear_conv(chat_id)
|
||||
return
|
||||
|
||||
# Edit the post-type selection message to show progress
|
||||
await self.edit_message(chat_id, message_id, "⏳ Erstelle deinen Post...")
|
||||
|
||||
try:
|
||||
user_uuid = UUID(user_id)
|
||||
|
||||
# Load everything the writer needs
|
||||
post_type = await db.get_post_type(UUID(post_type_id_str))
|
||||
if not post_type:
|
||||
await self.send_message(chat_id, "❌ Post-Typ nicht gefunden.")
|
||||
return
|
||||
|
||||
profile_analysis = await db.get_profile_analysis(user_uuid)
|
||||
if not profile_analysis:
|
||||
await self.send_message(chat_id, "❌ Profil-Analyse nicht gefunden. Bitte richte zuerst deinen Account in der App ein.")
|
||||
return
|
||||
|
||||
# Style examples — prefer type-specific, fall back to all
|
||||
linkedin_posts = await db.get_posts_by_type(user_uuid, UUID(post_type_id_str))
|
||||
if len(linkedin_posts) < 3:
|
||||
linkedin_posts = await db.get_linkedin_posts(user_uuid)
|
||||
example_post_texts = [
|
||||
p.post_text for p in linkedin_posts
|
||||
if p.post_text and len(p.post_text) > 100
|
||||
][:10]
|
||||
|
||||
# Company strategy if available
|
||||
company_strategy = None
|
||||
profile = await db.get_profile(user_uuid)
|
||||
if profile and profile.company_id:
|
||||
company = await db.get_company(profile.company_id)
|
||||
if company and company.company_strategy:
|
||||
company_strategy = company.company_strategy
|
||||
|
||||
# Single writer pass — no critic loop
|
||||
from src.agents.writer import WriterAgent
|
||||
writer = WriterAgent()
|
||||
writer.set_tracking_context(
|
||||
operation="post_creation",
|
||||
user_id=user_id,
|
||||
company_id=str(profile.company_id) if profile and profile.company_id else None,
|
||||
)
|
||||
post_content = await writer.process(
|
||||
topic={"title": topic_text, "fact": topic_text, "relevance": "User-specified topic"},
|
||||
profile_analysis=profile_analysis.full_analysis,
|
||||
example_posts=example_post_texts,
|
||||
post_type=post_type,
|
||||
user_thoughts=topic_text,
|
||||
company_strategy=company_strategy,
|
||||
strategy_weight=post_type.strategy_weight,
|
||||
)
|
||||
|
||||
# Use first sentence of generated post as title
|
||||
first_sentence = post_content.split("\n")[0].strip()
|
||||
if not first_sentence:
|
||||
first_sentence = post_content[:100].strip()
|
||||
|
||||
# Save the post to DB (status=draft, no critic data)
|
||||
from src.database.models import GeneratedPost
|
||||
saved = await db.save_generated_post(GeneratedPost(
|
||||
user_id=user_uuid,
|
||||
topic_title=first_sentence[:200],
|
||||
post_content=post_content,
|
||||
iterations=1,
|
||||
writer_versions=[post_content],
|
||||
status="draft",
|
||||
post_type_id=UUID(post_type_id_str),
|
||||
))
|
||||
|
||||
# Update conversation state
|
||||
await self._set_conv(chat_id, {
|
||||
"state": "waiting_feedback",
|
||||
"user_id": user_id,
|
||||
"topic": topic_text,
|
||||
"post_id": str(saved.id),
|
||||
"post_content": post_content,
|
||||
})
|
||||
|
||||
await self.send_message(
|
||||
chat_id,
|
||||
f"✨ <b>Generierter Post:</b>\n\n{post_content}",
|
||||
reply_markup=self._action_keyboard()
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to create post for user {user_id}: {e}")
|
||||
await self.send_message(chat_id, "❌ Fehler beim Erstellen des Posts. Bitte versuche es erneut.")
|
||||
await self._clear_conv(chat_id)
|
||||
|
||||
|
||||
# Module-level singleton — only created when Telegram is enabled
|
||||
telegram_service: Optional[TelegramService] = None
|
||||
if settings.telegram_enabled:
|
||||
telegram_service = TelegramService()
|
||||
@@ -4,8 +4,9 @@ from pathlib import Path
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from fastapi.responses import RedirectResponse
|
||||
from fastapi.responses import FileResponse, RedirectResponse
|
||||
from starlette.middleware.base import BaseHTTPMiddleware
|
||||
from starlette.middleware.gzip import GZipMiddleware
|
||||
from loguru import logger
|
||||
|
||||
from src.config import settings
|
||||
@@ -21,6 +22,17 @@ async def lifespan(app: FastAPI):
|
||||
from src.services.redis_client import get_redis, close_redis
|
||||
await get_redis()
|
||||
|
||||
# Register Telegram webhook if enabled
|
||||
if settings.telegram_enabled and settings.telegram_bot_token and settings.telegram_webhook_url:
|
||||
try:
|
||||
from src.services.telegram_service import telegram_service
|
||||
if telegram_service:
|
||||
webhook_url = f"{settings.telegram_webhook_url}/api/telegram/webhook"
|
||||
await telegram_service.register_webhook(webhook_url, settings.telegram_webhook_secret)
|
||||
logger.info("Telegram webhook registered")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to register Telegram webhook: {e}")
|
||||
|
||||
# Start scheduler only when this process is the dedicated scheduler container
|
||||
scheduler = None
|
||||
if settings.scheduler_enabled:
|
||||
@@ -49,6 +61,36 @@ async def lifespan(app: FastAPI):
|
||||
app = FastAPI(title="LinkedIn Post Creation System", lifespan=lifespan)
|
||||
|
||||
|
||||
class SecurityHeadersMiddleware(BaseHTTPMiddleware):
|
||||
"""Add security headers to every response."""
|
||||
|
||||
# CSP allows inline scripts/styles (app uses them extensively in Jinja2 templates).
|
||||
# frame-ancestors 'none' replaces X-Frame-Options for modern browsers.
|
||||
_CSP = (
|
||||
"default-src 'self'; "
|
||||
"script-src 'self' 'unsafe-inline' 'unsafe-eval' https://cdn.jsdelivr.net; "
|
||||
"style-src 'self' 'unsafe-inline'; "
|
||||
"img-src 'self' data: blob: https://*.supabase.co https://*.linkedin.com https://media.licdn.com; "
|
||||
"connect-src 'self' https://*.supabase.co; "
|
||||
"font-src 'self' data:; "
|
||||
"frame-ancestors 'none'; "
|
||||
"base-uri 'self'; "
|
||||
"form-action 'self';"
|
||||
)
|
||||
|
||||
async def dispatch(self, request, call_next):
|
||||
response = await call_next(request)
|
||||
h = response.headers
|
||||
h["X-Frame-Options"] = "DENY"
|
||||
h["X-Content-Type-Options"] = "nosniff"
|
||||
h["X-XSS-Protection"] = "1; mode=block"
|
||||
h["Referrer-Policy"] = "strict-origin-when-cross-origin"
|
||||
h["Permissions-Policy"] = "camera=(), microphone=(), geolocation=()"
|
||||
h["Strict-Transport-Security"] = "max-age=31536000; includeSubDomains"
|
||||
h["Content-Security-Policy"] = self._CSP
|
||||
return response
|
||||
|
||||
|
||||
class StaticCacheMiddleware(BaseHTTPMiddleware):
|
||||
"""Set long-lived Cache-Control headers on static assets."""
|
||||
|
||||
@@ -64,11 +106,24 @@ class StaticCacheMiddleware(BaseHTTPMiddleware):
|
||||
return response
|
||||
|
||||
|
||||
# Middleware executes in reverse registration order (last added = outermost).
|
||||
# Order: GZip compresses → StaticCache sets headers → SecurityHeaders sets headers.
|
||||
app.add_middleware(SecurityHeadersMiddleware)
|
||||
app.add_middleware(StaticCacheMiddleware)
|
||||
app.add_middleware(GZipMiddleware, minimum_size=500)
|
||||
|
||||
# Static files
|
||||
app.mount("/static", StaticFiles(directory=Path(__file__).parent / "static"), name="static")
|
||||
|
||||
|
||||
@app.get("/sw.js", include_in_schema=False)
|
||||
async def service_worker():
|
||||
"""Serve Service Worker from root scope so it can intercept all page requests."""
|
||||
response = FileResponse(Path(__file__).parent / "static/sw.js", media_type="application/javascript")
|
||||
response.headers["Service-Worker-Allowed"] = "/"
|
||||
response.headers["Cache-Control"] = "no-cache"
|
||||
return response
|
||||
|
||||
# Include admin router (always available)
|
||||
app.include_router(admin_router)
|
||||
|
||||
|
||||
48
src/web/static/sw.js
Normal file
48
src/web/static/sw.js
Normal file
@@ -0,0 +1,48 @@
|
||||
const CACHE_NAME = 'linkedin-shell-v1';
|
||||
const PRECACHE_URLS = [
|
||||
'/static/tailwind.css',
|
||||
'/static/tailwind-employee.css',
|
||||
'/static/logo.png',
|
||||
'/static/favicon.png',
|
||||
];
|
||||
|
||||
self.addEventListener('install', function(event) {
|
||||
event.waitUntil(
|
||||
caches.open(CACHE_NAME).then(function(cache) {
|
||||
return cache.addAll(PRECACHE_URLS);
|
||||
}).then(function() {
|
||||
return self.skipWaiting();
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
self.addEventListener('activate', function(event) {
|
||||
event.waitUntil(
|
||||
caches.keys().then(function(cacheNames) {
|
||||
return Promise.all(
|
||||
cacheNames
|
||||
.filter(function(name) { return name !== CACHE_NAME; })
|
||||
.map(function(name) { return caches.delete(name); })
|
||||
);
|
||||
}).then(function() {
|
||||
return self.clients.claim();
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
self.addEventListener('fetch', function(event) {
|
||||
if (!event.request.url.includes('/static/')) {
|
||||
return;
|
||||
}
|
||||
event.respondWith(
|
||||
caches.match(event.request).then(function(cached) {
|
||||
return cached || fetch(event.request).then(function(response) {
|
||||
var clone = response.clone();
|
||||
caches.open(CACHE_NAME).then(function(cache) {
|
||||
cache.put(event.request, clone);
|
||||
});
|
||||
return response;
|
||||
});
|
||||
})
|
||||
);
|
||||
});
|
||||
@@ -5,6 +5,7 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{% block title %}LinkedIn Posts{% endblock %}</title>
|
||||
<link rel="icon" type="image/png" href="/static/favicon.png">
|
||||
<link rel="preload" href="/static/tailwind.css" as="style">
|
||||
<link rel="stylesheet" href="/static/tailwind.css">
|
||||
<style>
|
||||
body { background-color: #3d4848; }
|
||||
@@ -362,5 +363,12 @@
|
||||
</script>
|
||||
|
||||
{% block scripts %}{% endblock %}
|
||||
<script>
|
||||
if ('serviceWorker' in navigator) {
|
||||
window.addEventListener('load', function() {
|
||||
navigator.serviceWorker.register('/sw.js').catch(function() {});
|
||||
});
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{% block title %}{{ session.company_name or 'Unternehmen' }} - LinkedIn Posts{% endblock %}</title>
|
||||
<link rel="icon" type="image/png" href="/static/favicon.png">
|
||||
<link rel="preload" href="/static/tailwind.css" as="style">
|
||||
<link rel="stylesheet" href="/static/tailwind.css">
|
||||
<style>
|
||||
body { background-color: #3d4848; }
|
||||
@@ -206,5 +207,12 @@
|
||||
</script>
|
||||
|
||||
{% block scripts %}{% endblock %}
|
||||
<script>
|
||||
if ('serviceWorker' in navigator) {
|
||||
window.addEventListener('load', function() {
|
||||
navigator.serviceWorker.register('/sw.js').catch(function() {});
|
||||
});
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -171,11 +171,20 @@
|
||||
<h1 class="text-2xl font-bold text-white mb-1">Meine Posts</h1>
|
||||
<p class="text-gray-400 text-sm">Ziehe Posts zwischen den Spalten um den Status zu ändern</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
{% if archived_count and archived_count > 0 %}
|
||||
<a href="/posts/archive" class="px-4 py-2.5 rounded-lg font-medium flex items-center gap-2 text-sm border border-gray-600 text-gray-300 hover:border-gray-400 hover:text-white transition-colors">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 8h14M5 8a2 2 0 110-4h14a2 2 0 110 4M5 8l1 12a2 2 0 002 2h8a2 2 0 002-2L19 8M10 12v4M14 12v4"/></svg>
|
||||
Archiv
|
||||
<span class="bg-gray-700 text-gray-300 text-xs px-1.5 py-0.5 rounded-full">{{ archived_count }}</span>
|
||||
</a>
|
||||
{% endif %}
|
||||
<a href="/create" class="px-4 py-2.5 btn-primary rounded-lg font-medium flex items-center gap-2">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6v6m0 0v6m0-6h6m-6 0H6"/></svg>
|
||||
Neuer Post
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if error %}
|
||||
<div class="bg-red-900/50 border border-red-500 text-red-200 px-4 py-3 rounded-lg mb-6">
|
||||
@@ -253,8 +262,17 @@
|
||||
<div class="w-20 h-20 bg-brand-bg rounded-2xl flex items-center justify-center mx-auto mb-6">
|
||||
<svg class="w-10 h-10 text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/></svg>
|
||||
</div>
|
||||
{% if archived_count and archived_count > 0 %}
|
||||
<h3 class="text-xl font-semibold text-white mb-2">Alle Posts veröffentlicht</h3>
|
||||
<p class="text-gray-400 mb-6 max-w-md mx-auto">Keine aktiven Posts. {{ archived_count }} Post{{ 's' if archived_count != 1 else '' }} im Archiv.</p>
|
||||
<a href="/posts/archive" class="inline-flex items-center gap-2 px-6 py-3 border border-gray-600 text-gray-300 hover:text-white font-medium rounded-lg transition-colors mr-3">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 8h14M5 8a2 2 0 110-4h14a2 2 0 110 4M5 8l1 12a2 2 0 002 2h8a2 2 0 002-2L19 8M10 12v4M14 12v4"/></svg>
|
||||
Archiv ansehen
|
||||
</a>
|
||||
{% else %}
|
||||
<h3 class="text-xl font-semibold text-white mb-2">Noch keine Posts</h3>
|
||||
<p class="text-gray-400 mb-6 max-w-md mx-auto">Erstelle deinen ersten LinkedIn Post mit KI-Unterstützung.</p>
|
||||
{% endif %}
|
||||
<a href="/create" class="inline-flex items-center gap-2 px-6 py-3 btn-primary font-medium rounded-lg transition-colors">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6v6m0 0v6m0-6h6m-6 0H6"/></svg>
|
||||
Post erstellen
|
||||
|
||||
118
src/web/templates/user/posts_archive.html
Normal file
118
src/web/templates/user/posts_archive.html
Normal file
@@ -0,0 +1,118 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Post-Archiv - LinkedIn Posts{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="mb-6 flex items-center justify-between">
|
||||
<div>
|
||||
<div class="flex items-center gap-3 mb-1">
|
||||
<a href="/posts" class="text-gray-400 hover:text-white transition-colors flex items-center gap-1 text-sm">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"/></svg>
|
||||
Zurück
|
||||
</a>
|
||||
<h1 class="text-2xl font-bold text-white">Post-Archiv</h1>
|
||||
</div>
|
||||
<p class="text-gray-400 text-sm">Veröffentlichte, geplante und abgelehnte Posts ({{ total }} gesamt)</p>
|
||||
</div>
|
||||
<a href="/create" class="px-4 py-2.5 btn-primary rounded-lg font-medium flex items-center gap-2">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6v6m0 0v6m0-6h6m-6 0H6"/></svg>
|
||||
Neuer Post
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{% if error %}
|
||||
<div class="bg-red-900/50 border border-red-500 text-red-200 px-4 py-3 rounded-lg mb-6">
|
||||
<strong>Error:</strong> {{ error }}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if not posts and not error %}
|
||||
<div class="card-bg rounded-xl border p-12 text-center">
|
||||
<div class="w-20 h-20 bg-brand-bg rounded-2xl flex items-center justify-center mx-auto mb-6">
|
||||
<svg class="w-10 h-10 text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 8h14M5 8a2 2 0 110-4h14a2 2 0 110 4M5 8l1 12a2 2 0 002 2h8a2 2 0 002-2L19 8M10 12v4M14 12v4"/></svg>
|
||||
</div>
|
||||
<h3 class="text-xl font-semibold text-white mb-2">Kein Archiv vorhanden</h3>
|
||||
<p class="text-gray-400 mb-6 max-w-md mx-auto">Noch keine veröffentlichten oder abgelehnten Posts.</p>
|
||||
<a href="/posts" class="inline-flex items-center gap-2 px-6 py-3 btn-primary font-medium rounded-lg transition-colors">
|
||||
Zurück zu den Posts
|
||||
</a>
|
||||
</div>
|
||||
{% else %}
|
||||
|
||||
<div class="space-y-3">
|
||||
{% for post in posts %}
|
||||
<a href="/posts/{{ post.id }}" class="block card-bg rounded-xl border hover:border-gray-500 transition-colors p-4">
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center gap-2 mb-1">
|
||||
{% if post.status == 'published' %}
|
||||
<span class="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium bg-green-900/50 text-green-300 border border-green-800">
|
||||
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/></svg>
|
||||
Veröffentlicht
|
||||
</span>
|
||||
{% elif post.status == 'scheduled' %}
|
||||
<span class="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium bg-blue-900/50 text-blue-300 border border-blue-800">
|
||||
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
|
||||
Geplant
|
||||
</span>
|
||||
{% elif post.status == 'rejected' %}
|
||||
<span class="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium bg-red-900/50 text-red-300 border border-red-800">
|
||||
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/></svg>
|
||||
Abgelehnt
|
||||
</span>
|
||||
{% endif %}
|
||||
<h4 class="font-medium text-white truncate">{{ post.topic_title or 'Untitled' }}</h4>
|
||||
</div>
|
||||
{% if post.post_content %}
|
||||
<p class="text-gray-400 text-sm line-clamp-2">{{ post.post_content[:200] }}{% if post.post_content | length > 200 %}...{% endif %}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="flex-shrink-0 text-right text-xs text-gray-500 space-y-1">
|
||||
{% if post.status == 'published' and post.published_at %}
|
||||
<div>veröffentlicht {{ post.published_at.strftime('%d.%m.%Y') }}</div>
|
||||
{% elif post.status == 'scheduled' and post.scheduled_at %}
|
||||
<div>geplant für {{ post.scheduled_at.strftime('%d.%m.%Y %H:%M') }}</div>
|
||||
{% else %}
|
||||
<div>erstellt {{ post.created_at.strftime('%d.%m.%Y') if post.created_at else 'N/A' }}</div>
|
||||
{% endif %}
|
||||
{% if post.critic_feedback and post.critic_feedback | length > 0 and post.critic_feedback[-1].get('overall_score') is not none %}
|
||||
{% set score = post.critic_feedback[-1].get('overall_score', 0) %}
|
||||
<div class="inline-flex items-center px-1.5 py-0.5 rounded-full text-xs font-semibold
|
||||
{{ 'bg-green-900/50 text-green-300' if score >= 85 else 'bg-yellow-900/50 text-yellow-300' if score >= 70 else 'bg-red-900/50 text-red-300' }}">
|
||||
{{ score }}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
{% if total_pages > 1 %}
|
||||
<div class="flex items-center justify-center gap-2 mt-8">
|
||||
{% if current_page > 1 %}
|
||||
<a href="/posts/archive?page={{ current_page - 1 }}" class="px-3 py-2 rounded-lg border border-gray-600 text-gray-300 hover:text-white hover:border-gray-400 transition-colors text-sm">
|
||||
← Zurück
|
||||
</a>
|
||||
{% endif %}
|
||||
|
||||
{% for p in range(1, total_pages + 1) %}
|
||||
{% if p == current_page %}
|
||||
<span class="px-3 py-2 rounded-lg bg-brand-bg text-white text-sm font-medium border border-yellow-500">{{ p }}</span>
|
||||
{% elif p <= 2 or p >= total_pages - 1 or (p >= current_page - 2 and p <= current_page + 2) %}
|
||||
<a href="/posts/archive?page={{ p }}" class="px-3 py-2 rounded-lg border border-gray-600 text-gray-300 hover:text-white hover:border-gray-400 transition-colors text-sm">{{ p }}</a>
|
||||
{% elif p == 3 or p == total_pages - 2 %}
|
||||
<span class="text-gray-500 px-1">…</span>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
|
||||
{% if current_page < total_pages %}
|
||||
<a href="/posts/archive?page={{ current_page + 1 }}" class="px-3 py-2 rounded-lg border border-gray-600 text-gray-300 hover:text-white hover:border-gray-400 transition-colors text-sm">
|
||||
Weiter →
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
<p class="text-center text-xs text-gray-500 mt-2">Seite {{ current_page }} von {{ total_pages }} ({{ total }} Posts)</p>
|
||||
{% endif %}
|
||||
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
@@ -169,6 +169,71 @@
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Telegram Bot Connection -->
|
||||
{% if telegram_enabled %}
|
||||
<div class="card-bg rounded-xl border border-gray-700 p-6 mb-6">
|
||||
<h2 class="text-xl font-semibold text-white mb-4 flex items-center gap-2">
|
||||
<svg class="w-5 h-5 text-[#26A5E4]" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M11.944 0A12 12 0 0 0 0 12a12 12 0 0 0 12 12 12 12 0 0 0 12-12A12 12 0 0 0 12 0a12 12 0 0 0-.056 0zm4.962 7.224c.1-.002.321.023.465.14a.506.506 0 0 1 .171.325c.016.093.036.306.02.472-.18 1.898-.962 6.502-1.36 8.627-.168.9-.499 1.201-.82 1.23-.696.065-1.225-.46-1.9-.902-1.056-.693-1.653-1.124-2.678-1.8-1.185-.78-.417-1.21.258-1.91.177-.184 3.247-2.977 3.307-3.23.007-.032.014-.15-.056-.212s-.174-.041-.249-.024c-.106.024-1.793 1.14-5.061 3.345-.48.33-.913.49-1.302.48-.428-.008-1.252-.241-1.865-.44-.752-.245-1.349-.374-1.297-.789.027-.216.325-.437.893-.663 3.498-1.524 5.83-2.529 6.998-3.014 3.332-1.386 4.025-1.627 4.476-1.635z"/>
|
||||
</svg>
|
||||
Telegram verbinden
|
||||
<span class="text-xs bg-yellow-500/20 text-yellow-400 px-2 py-0.5 rounded ml-2">Experimentell</span>
|
||||
</h2>
|
||||
|
||||
{% if telegram_account %}
|
||||
<!-- Connected State -->
|
||||
<div class="bg-green-900/20 border border-green-600 rounded-lg p-4 mb-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<svg class="w-5 h-5 text-green-400 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/>
|
||||
</svg>
|
||||
<div>
|
||||
<p class="text-white font-medium">
|
||||
{% if telegram_account.telegram_username %}
|
||||
@{{ telegram_account.telegram_username }}
|
||||
{% elif telegram_account.telegram_first_name %}
|
||||
{{ telegram_account.telegram_first_name }}
|
||||
{% else %}
|
||||
Telegram verbunden
|
||||
{% endif %}
|
||||
</p>
|
||||
{% if telegram_account.created_at %}
|
||||
<p class="text-gray-400 text-sm mt-0.5">
|
||||
Verbunden seit {{ telegram_account.created_at.strftime('%d.%m.%Y') }}
|
||||
</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button onclick="disconnectTelegram()"
|
||||
class="px-6 py-3 bg-red-600 hover:bg-red-700 text-white rounded-lg transition-colors flex items-center gap-2">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
|
||||
</svg>
|
||||
Verbindung trennen
|
||||
</button>
|
||||
{% else %}
|
||||
<!-- Not Connected State -->
|
||||
<p class="text-gray-400 mb-4">
|
||||
Verbinde Telegram, um LinkedIn-Posts direkt per Chat zu erstellen – ohne die Web-App öffnen zu müssen.
|
||||
</p>
|
||||
<button onclick="connectTelegram()"
|
||||
class="inline-flex items-center gap-2 px-6 py-3 bg-[#26A5E4] hover:bg-[#1a8bc4] text-white rounded-lg transition-colors">
|
||||
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M11.944 0A12 12 0 0 0 0 12a12 12 0 0 0 12 12 12 12 0 0 0 12-12A12 12 0 0 0 12 0a12 12 0 0 0-.056 0zm4.962 7.224c.1-.002.321.023.465.14a.506.506 0 0 1 .171.325c.016.093.036.306.02.472-.18 1.898-.962 6.502-1.36 8.627-.168.9-.499 1.201-.82 1.23-.696.065-1.225-.46-1.9-.902-1.056-.693-1.653-1.124-2.678-1.8-1.185-.78-.417-1.21.258-1.91.177-.184 3.247-2.977 3.307-3.23.007-.032.014-.15-.056-.212s-.174-.041-.249-.024c-.106.024-1.793 1.14-5.061 3.345-.48.33-.913.49-1.302.48-.428-.008-1.252-.241-1.865-.44-.752-.245-1.349-.374-1.297-.789.027-.216.325-.437.893-.663 3.498-1.524 5.83-2.529 6.998-3.014 3.332-1.386 4.025-1.627 4.476-1.635z"/>
|
||||
</svg>
|
||||
Mit Telegram verbinden
|
||||
</button>
|
||||
<div id="telegramLinkBox" class="hidden mt-4 p-4 bg-brand-bg-light rounded-lg border border-brand-bg-light">
|
||||
<p class="text-sm text-gray-400 mb-2">Öffne diesen Link in Telegram:</p>
|
||||
<a id="telegramLink" href="#" target="_blank" rel="noopener"
|
||||
class="text-[#26A5E4] hover:underline break-all text-sm font-mono"></a>
|
||||
<p class="text-xs text-gray-500 mt-2">Der Link ist 10 Minuten gültig.</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Workflow Info -->
|
||||
<div class="card-bg rounded-xl border p-6">
|
||||
<h2 class="text-lg font-semibold text-white mb-4 flex items-center gap-2">
|
||||
@@ -255,6 +320,36 @@ document.getElementById('emailSettingsForm').addEventListener('submit', async (e
|
||||
}
|
||||
});
|
||||
|
||||
// Telegram connect
|
||||
async function connectTelegram() {
|
||||
try {
|
||||
const res = await fetch('/settings/telegram/start');
|
||||
if (!res.ok) throw new Error('Fehler beim Generieren des Links');
|
||||
const data = await res.json();
|
||||
const link = `https://t.me/${data.bot_username}?start=${data.token}`;
|
||||
document.getElementById('telegramLink').href = link;
|
||||
document.getElementById('telegramLink').textContent = link;
|
||||
document.getElementById('telegramLinkBox').classList.remove('hidden');
|
||||
} catch (error) {
|
||||
console.error('Error connecting Telegram:', error);
|
||||
alert('Fehler: ' + error.message);
|
||||
}
|
||||
}
|
||||
|
||||
// Telegram disconnect
|
||||
async function disconnectTelegram() {
|
||||
if (!confirm('Telegram-Verbindung wirklich trennen?')) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await fetch('/api/settings/telegram/disconnect', { method: 'POST' });
|
||||
window.location.reload();
|
||||
} catch (error) {
|
||||
console.error('Error disconnecting Telegram:', error);
|
||||
alert('Fehler: ' + error.message);
|
||||
}
|
||||
}
|
||||
|
||||
// LinkedIn disconnect
|
||||
async function disconnectLinkedIn() {
|
||||
if (!confirm('LinkedIn-Verbindung wirklich trennen?\n\nPosts werden dann nicht mehr automatisch veröffentlicht und du erhältst wieder Email-Benachrichtigungen.')) {
|
||||
|
||||
@@ -229,7 +229,8 @@ def set_user_session(response: Response, session: UserSession, access_token: str
|
||||
value=session.to_cookie_value(),
|
||||
httponly=True,
|
||||
max_age=60 * 60 * 24 * 7,
|
||||
samesite="lax"
|
||||
samesite="lax",
|
||||
secure=True,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -1338,9 +1338,13 @@ async def dashboard(request: Request):
|
||||
})
|
||||
|
||||
|
||||
_ACTIVE_STATUSES = {"draft", "approved", "ready"}
|
||||
_ARCHIVE_STATUSES = {"scheduled", "published", "rejected"}
|
||||
|
||||
|
||||
@user_router.get("/posts", response_class=HTMLResponse)
|
||||
async def posts_page(request: Request):
|
||||
"""View user's own posts."""
|
||||
"""View user's own posts (active Kanban only)."""
|
||||
session = require_user_session(request)
|
||||
if not session:
|
||||
return RedirectResponse(url="/login", status_code=302)
|
||||
@@ -1348,7 +1352,9 @@ async def posts_page(request: Request):
|
||||
try:
|
||||
user_id = UUID(session.user_id)
|
||||
profile = await db.get_profile(user_id)
|
||||
posts = await db.get_generated_posts(user_id)
|
||||
all_posts = await db.get_generated_posts(user_id)
|
||||
active_posts = [p for p in all_posts if p.status in _ACTIVE_STATUSES]
|
||||
archived_count = sum(1 for p in all_posts if p.status in _ARCHIVE_STATUSES)
|
||||
profile_picture = await get_user_avatar(session, user_id)
|
||||
|
||||
return templates.TemplateResponse("posts.html", {
|
||||
@@ -1356,8 +1362,9 @@ async def posts_page(request: Request):
|
||||
"page": "posts",
|
||||
"session": session,
|
||||
"profile": profile,
|
||||
"posts": posts,
|
||||
"total_posts": len(posts),
|
||||
"posts": active_posts,
|
||||
"total_posts": len(active_posts),
|
||||
"archived_count": archived_count,
|
||||
"profile_picture": profile_picture
|
||||
})
|
||||
except Exception as e:
|
||||
@@ -1370,6 +1377,61 @@ async def posts_page(request: Request):
|
||||
"session": session,
|
||||
"posts": [],
|
||||
"total_posts": 0,
|
||||
"archived_count": 0,
|
||||
"error": str(e)
|
||||
})
|
||||
|
||||
|
||||
@user_router.get("/posts/archive", response_class=HTMLResponse)
|
||||
async def posts_archive_page(request: Request, page: int = 1):
|
||||
"""View archived posts (published, scheduled, rejected)."""
|
||||
session = require_user_session(request)
|
||||
if not session:
|
||||
return RedirectResponse(url="/login", status_code=302)
|
||||
|
||||
try:
|
||||
user_id = UUID(session.user_id)
|
||||
all_posts = await db.get_generated_posts(user_id)
|
||||
archived_posts = [p for p in all_posts if p.status in _ARCHIVE_STATUSES]
|
||||
# Sort: scheduled first (upcoming), then by published_at/created_at desc
|
||||
archived_posts.sort(
|
||||
key=lambda p: (
|
||||
p.status != "scheduled",
|
||||
-(p.published_at or p.created_at or datetime.min.replace(tzinfo=timezone.utc)).timestamp()
|
||||
)
|
||||
)
|
||||
|
||||
per_page = 20
|
||||
total = len(archived_posts)
|
||||
total_pages = max(1, (total + per_page - 1) // per_page)
|
||||
page = max(1, min(page, total_pages))
|
||||
start = (page - 1) * per_page
|
||||
page_posts = archived_posts[start:start + per_page]
|
||||
|
||||
profile_picture = await get_user_avatar(session, user_id)
|
||||
|
||||
return templates.TemplateResponse("posts_archive.html", {
|
||||
"request": request,
|
||||
"page": "posts",
|
||||
"session": session,
|
||||
"posts": page_posts,
|
||||
"total": total,
|
||||
"current_page": page,
|
||||
"total_pages": total_pages,
|
||||
"per_page": per_page,
|
||||
"profile_picture": profile_picture
|
||||
})
|
||||
except Exception as e:
|
||||
logger.error(f"Error loading posts archive: {e}")
|
||||
return templates.TemplateResponse("posts_archive.html", {
|
||||
"request": request,
|
||||
"page": "posts",
|
||||
"session": session,
|
||||
"posts": [],
|
||||
"total": 0,
|
||||
"current_page": 1,
|
||||
"total_pages": 1,
|
||||
"per_page": 20,
|
||||
"error": str(e)
|
||||
})
|
||||
|
||||
@@ -1633,6 +1695,7 @@ async def post_detail_page(request: Request, post_id: str):
|
||||
"post_type_analysis": post_type_analysis,
|
||||
"final_feedback": final_feedback,
|
||||
"profile_picture_url": profile_picture_url,
|
||||
"profile_picture": profile_picture_url,
|
||||
"media_items_dict": media_items_dict,
|
||||
"limit_reached": limit_reached,
|
||||
"limit_message": limit_message
|
||||
@@ -2356,13 +2419,20 @@ async def settings_page(request: Request):
|
||||
# Get LinkedIn account if linked
|
||||
linkedin_account = await db.get_linkedin_account(user_id)
|
||||
|
||||
# Get Telegram account if feature is enabled
|
||||
telegram_account = None
|
||||
if settings.telegram_enabled:
|
||||
telegram_account = await db.get_telegram_account(user_id)
|
||||
|
||||
return templates.TemplateResponse("settings.html", {
|
||||
"request": request,
|
||||
"page": "settings",
|
||||
"session": session,
|
||||
"profile": profile,
|
||||
"profile_picture": profile_picture,
|
||||
"linkedin_account": linkedin_account
|
||||
"linkedin_account": linkedin_account,
|
||||
"telegram_enabled": settings.telegram_enabled,
|
||||
"telegram_account": telegram_account,
|
||||
})
|
||||
except Exception as e:
|
||||
logger.error(f"Error loading settings: {e}")
|
||||
@@ -4657,3 +4727,57 @@ async def proxy_supabase_image(bucket: str, path: str):
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to proxy image {bucket}/{path}: {e}")
|
||||
raise HTTPException(status_code=500, detail="Failed to load image")
|
||||
|
||||
|
||||
# ==================== TELEGRAM BOT ====================
|
||||
|
||||
if settings.telegram_enabled:
|
||||
import secrets as _secrets
|
||||
from src.services.telegram_service import telegram_service as _telegram_service
|
||||
|
||||
@user_router.get("/settings/telegram/start")
|
||||
async def telegram_start_link(request: Request):
|
||||
"""Generate a one-time Telegram linking token and return the bot link."""
|
||||
session = require_user_session(request)
|
||||
if not session:
|
||||
raise HTTPException(status_code=401, detail="Not authenticated")
|
||||
|
||||
from src.services.redis_client import get_redis
|
||||
token = _secrets.token_urlsafe(32)
|
||||
try:
|
||||
redis = await get_redis()
|
||||
await redis.setex(f"telegram_link:{token}", 600, session.user_id)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to store telegram link token: {e}")
|
||||
raise HTTPException(status_code=500, detail="Failed to generate link")
|
||||
|
||||
return JSONResponse({
|
||||
"bot_username": settings.telegram_bot_username,
|
||||
"token": token
|
||||
})
|
||||
|
||||
@user_router.post("/api/telegram/webhook")
|
||||
async def telegram_webhook(request: Request):
|
||||
"""Receive Telegram webhook updates."""
|
||||
secret = request.headers.get("X-Telegram-Bot-Api-Secret-Token", "")
|
||||
if secret != settings.telegram_webhook_secret:
|
||||
raise HTTPException(status_code=403, detail="Invalid secret")
|
||||
|
||||
try:
|
||||
update = await request.json()
|
||||
if _telegram_service:
|
||||
await _telegram_service.handle_update(update, db)
|
||||
except Exception as e:
|
||||
logger.error(f"Telegram webhook error: {e}")
|
||||
# Always return 200 — Telegram requires it
|
||||
return {"ok": True}
|
||||
|
||||
@user_router.post("/api/settings/telegram/disconnect")
|
||||
async def telegram_disconnect(request: Request):
|
||||
"""Disconnect Telegram account."""
|
||||
session = require_user_session(request)
|
||||
if not session:
|
||||
raise HTTPException(status_code=401, detail="Not authenticated")
|
||||
|
||||
await db.delete_telegram_account(UUID(session.user_id))
|
||||
return JSONResponse({"success": True})
|
||||
|
||||
Reference in New Issue
Block a user