add telegram post creation feature but experimental
This commit is contained in:
@@ -69,6 +69,13 @@ class Settings(BaseSettings):
|
|||||||
redis_url: str = "redis://redis:6379/0"
|
redis_url: str = "redis://redis:6379/0"
|
||||||
scheduler_enabled: bool = False # True only on dedicated scheduler container
|
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(
|
model_config = SettingsConfigDict(
|
||||||
env_file=".env",
|
env_file=".env",
|
||||||
env_file_encoding="utf-8",
|
env_file_encoding="utf-8",
|
||||||
|
|||||||
@@ -817,6 +817,63 @@ class DatabaseClient:
|
|||||||
await cache.invalidate_linkedin_account(user_id)
|
await cache.invalidate_linkedin_account(user_id)
|
||||||
logger.info(f"Deleted LinkedIn account: {account_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 ====================
|
# ==================== USERS ====================
|
||||||
|
|
||||||
async def get_user(self, user_id: UUID) -> Optional[User]:
|
async def get_user(self, user_id: UUID) -> Optional[User]:
|
||||||
|
|||||||
@@ -103,6 +103,22 @@ class LinkedInAccount(DBModel):
|
|||||||
last_error_at: Optional[datetime] = None
|
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):
|
class User(DBModel):
|
||||||
"""User model - combines auth.users data with profile data.
|
"""User model - combines auth.users data with profile data.
|
||||||
|
|
||||||
|
|||||||
383
src/services/telegram_service.py
Normal file
383
src/services/telegram_service.py
Normal file
@@ -0,0 +1,383 @@
|
|||||||
|
"""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)
|
||||||
|
)
|
||||||
|
|
||||||
|
# ==================== 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
|
||||||
|
|
||||||
|
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 if we have a post_id
|
||||||
|
if post_id:
|
||||||
|
await db.update_generated_post(UUID(post_id), {"post_content": improved})
|
||||||
|
|
||||||
|
# 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."""
|
||||||
|
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
|
||||||
|
|
||||||
|
# Edit the post-type selection message to show progress
|
||||||
|
await self.edit_message(chat_id, message_id, "⏳ Erstelle deinen Post... Das kann einen Moment dauern.")
|
||||||
|
|
||||||
|
try:
|
||||||
|
from src.orchestrator import orchestrator
|
||||||
|
result = await orchestrator.create_post(
|
||||||
|
user_id=UUID(user_id),
|
||||||
|
topic={"title": topic_text, "description": topic_text},
|
||||||
|
post_type_id=UUID(post_type_id_str),
|
||||||
|
max_iterations=2 # Fewer iterations for faster Telegram response
|
||||||
|
)
|
||||||
|
|
||||||
|
post_content = result.get("final_post", "")
|
||||||
|
post_id = str(result.get("post_id", ""))
|
||||||
|
|
||||||
|
# Update conversation state
|
||||||
|
new_conv = {
|
||||||
|
"state": "waiting_feedback",
|
||||||
|
"user_id": user_id,
|
||||||
|
"topic": topic_text,
|
||||||
|
"post_id": post_id,
|
||||||
|
"post_content": post_content
|
||||||
|
}
|
||||||
|
await self._set_conv(chat_id, new_conv)
|
||||||
|
|
||||||
|
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()
|
||||||
@@ -22,6 +22,17 @@ async def lifespan(app: FastAPI):
|
|||||||
from src.services.redis_client import get_redis, close_redis
|
from src.services.redis_client import get_redis, close_redis
|
||||||
await get_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
|
# Start scheduler only when this process is the dedicated scheduler container
|
||||||
scheduler = None
|
scheduler = None
|
||||||
if settings.scheduler_enabled:
|
if settings.scheduler_enabled:
|
||||||
|
|||||||
@@ -169,6 +169,71 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</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 -->
|
<!-- Workflow Info -->
|
||||||
<div class="card-bg rounded-xl border p-6">
|
<div class="card-bg rounded-xl border p-6">
|
||||||
<h2 class="text-lg font-semibold text-white mb-4 flex items-center gap-2">
|
<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
|
// LinkedIn disconnect
|
||||||
async function disconnectLinkedIn() {
|
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.')) {
|
if (!confirm('LinkedIn-Verbindung wirklich trennen?\n\nPosts werden dann nicht mehr automatisch veröffentlicht und du erhältst wieder Email-Benachrichtigungen.')) {
|
||||||
|
|||||||
@@ -2419,13 +2419,20 @@ async def settings_page(request: Request):
|
|||||||
# Get LinkedIn account if linked
|
# Get LinkedIn account if linked
|
||||||
linkedin_account = await db.get_linkedin_account(user_id)
|
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", {
|
return templates.TemplateResponse("settings.html", {
|
||||||
"request": request,
|
"request": request,
|
||||||
"page": "settings",
|
"page": "settings",
|
||||||
"session": session,
|
"session": session,
|
||||||
"profile": profile,
|
"profile": profile,
|
||||||
"profile_picture": profile_picture,
|
"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:
|
except Exception as e:
|
||||||
logger.error(f"Error loading settings: {e}")
|
logger.error(f"Error loading settings: {e}")
|
||||||
@@ -4720,3 +4727,57 @@ async def proxy_supabase_image(bucket: str, path: str):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to proxy image {bucket}/{path}: {e}")
|
logger.error(f"Failed to proxy image {bucket}/{path}: {e}")
|
||||||
raise HTTPException(status_code=500, detail="Failed to load image")
|
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