Compare commits

..

10 Commits

15 changed files with 1043 additions and 13 deletions

View File

@@ -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 . .

View File

@@ -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

View File

@@ -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",

View File

@@ -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]:

View File

@@ -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.

View 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()

View File

@@ -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
View 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;
});
})
);
});

View File

@@ -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>

View File

@@ -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>

View File

@@ -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

View 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">
&larr; 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 &rarr;
</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 %}

View File

@@ -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.')) {

View File

@@ -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,
)

View File

@@ -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})