Implement phase 3 and 4: Telegram and Twitter integration

This commit is contained in:
2026-01-18 22:42:48 +03:00
parent 836e8df344
commit f4725a5506
11 changed files with 726 additions and 7 deletions
+3 -3
View File
@@ -121,7 +121,7 @@ telepost-clone/
## Дальнейшие этапы ## Дальнейшие этапы
2. **Этап 2**: Создание и редактирование постов 2. **Этап 2**: Создание и редактирование постов [x]
3. **Этап 3**: Публикация в Telegram 3. **Этап 3**: Публикация в Telegram [x]
4. **Этап 4**: Добавление интеграции с внешней платформой 4. **Этап 4**: Добавление интеграции с внешней платформой [x]
5. **Этап 5**: Расширенные функции (планирование, история, черновики) 5. **Этап 5**: Расширенные функции (планирование, история, черновики)
+2 -2
View File
@@ -83,8 +83,8 @@ telepost-clone/
- `media_files`: Загруженные медиафайлы - `media_files`: Загруженные медиафайлы
## 4. Потоки работы ## 4. Потоки работы
### 4.1. Аутентификация ### 4.1. Аутентификация
``` ```
1. Пользователь переходит на /login 1. Пользователь переходит на /login
2. Нажимает кнопку "Войти через Telegram" 2. Нажимает кнопку "Войти через Telegram"
@@ -96,7 +96,7 @@ telepost-clone/
``` ```
### 4.2. Создание и публикация поста ### 4.2. Создание и публикация поста
``` ```text
1. Пользователь создает новый пост 1. Пользователь создает новый пост
2. Пользователь выбирает каналы для публикации 2. Пользователь выбирает каналы для публикации
3. Пользователь может назначить время публикации 3. Пользователь может назначить время публикации
+81
View File
@@ -99,3 +99,84 @@ def delete_post(
detail="Post not found" detail="Post not found"
) )
return {"message": "Post deleted successfully"} return {"message": "Post deleted successfully"}
@router.post("/{post_id}/publish_telegram")
def publish_post_to_telegram(
post_id: int,
chat_id: str,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""
Опубликовать пост в Telegram
"""
post_service = PostService(db)
# Проверяем, что пост принадлежит пользователю
post = post_service.get_post(post_id=post_id, owner_id=current_user.id)
if not post:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Post not found"
)
try:
success = post_service.publish_post_to_telegram(post_id=post_id, chat_id=chat_id)
if success:
return {"message": "Post published to Telegram successfully", "success": True}
else:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to publish post to Telegram"
)
except ValueError as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=str(e)
)
except Exception as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Error publishing post to Telegram: {str(e)}"
)
@router.post("/{post_id}/publish_twitter")
def publish_post_to_twitter(
post_id: int,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""
Опубликовать пост в Twitter
"""
post_service = PostService(db)
# Проверяем, что пост принадлежит пользователю
post = post_service.get_post(post_id=post_id, owner_id=current_user.id)
if not post:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Post not found"
)
try:
success = post_service.publish_post_to_twitter(post_id=post_id)
if success:
return {"message": "Post published to Twitter successfully", "success": True}
else:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to publish post to Twitter"
)
except ValueError as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=str(e)
)
except Exception as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Error publishing post to Twitter: {str(e)}"
)
+4
View File
@@ -0,0 +1,4 @@
from .telegram_bot import TelegramBot
from .twitter_api import TwitterAPI
__all__ = ["TelegramBot", "TwitterAPI"]
+127
View File
@@ -0,0 +1,127 @@
import requests
from typing import Dict, Optional
from fastapi import HTTPException
import logging
logger = logging.getLogger(__name__)
class TelegramBot:
def __init__(self, bot_token: str):
self.bot_token = bot_token
self.base_url = f"https://api.telegram.org/bot{bot_token}"
def send_message(self, chat_id: str, text: str, parse_mode: str = "HTML") -> Dict:
"""
Отправить текстовое сообщение в Telegram
Args:
chat_id: ID чата или канала
text: Текст сообщения
parse_mode: Режим парсинга ('HTML' или 'MarkdownV2')
Returns:
Ответ от Telegram API
"""
url = f"{self.base_url}/sendMessage"
payload = {
"chat_id": chat_id,
"text": text,
"parse_mode": parse_mode,
"disable_web_page_preview": True
}
try:
response = requests.post(url, json=payload)
response.raise_for_status()
result = response.json()
if not result.get("ok"):
raise HTTPException(status_code=400, detail=f"Telegram API error: {result.get('description')}")
return result
except requests.exceptions.RequestException as e:
logger.error(f"Error sending message to Telegram: {str(e)}")
raise HTTPException(status_code=500, detail=f"Failed to send message to Telegram: {str(e)}")
def send_photo(self, chat_id: str, photo_url: str, caption: str = "", parse_mode: str = "HTML") -> Dict:
"""
Отправить фото в Telegram
Args:
chat_id: ID чата или канала
photo_url: URL фото
caption: Подпись к фото
parse_mode: Режим парсинга ('HTML' или 'MarkdownV2')
Returns:
Ответ от Telegram API
"""
url = f"{self.base_url}/sendPhoto"
payload = {
"chat_id": chat_id,
"photo": photo_url,
"caption": caption,
"parse_mode": parse_mode
}
try:
response = requests.post(url, json=payload)
response.raise_for_status()
result = response.json()
if not result.get("ok"):
raise HTTPException(status_code=400, detail=f"Telegram API error: {result.get('description')}")
return result
except requests.exceptions.RequestException as e:
logger.error(f"Error sending photo to Telegram: {str(e)}")
raise HTTPException(status_code=500, detail=f"Failed to send photo to Telegram: {str(e)}")
def get_me(self) -> Dict:
"""
Получить информацию о боте
Returns:
Информация о боте от Telegram API
"""
url = f"{self.base_url}/getMe"
try:
response = requests.get(url)
response.raise_for_status()
result = response.json()
if not result.get("ok"):
raise HTTPException(status_code=400, detail=f"Telegram API error: {result.get('description')}")
return result
except requests.exceptions.RequestException as e:
logger.error(f"Error getting bot info from Telegram: {str(e)}")
raise HTTPException(status_code=500, detail=f"Failed to get bot info from Telegram: {str(e)}")
def get_chat(self, chat_id: str) -> Dict:
"""
Получить информацию о чате
Args:
chat_id: ID чата или канала
Returns:
Информация о чате от Telegram API
"""
url = f"{self.base_url}/getChat"
payload = {"chat_id": chat_id}
try:
response = requests.post(url, json=payload)
response.raise_for_status()
result = response.json()
if not result.get("ok"):
raise HTTPException(status_code=400, detail=f"Telegram API error: {result.get('description')}")
return result
except requests.exceptions.RequestException as e:
logger.error(f"Error getting chat info from Telegram: {str(e)}")
raise HTTPException(status_code=500, detail=f"Failed to get chat info from Telegram: {str(e)}")
+126
View File
@@ -0,0 +1,126 @@
import tweepy
from typing import Dict, Optional
from fastapi import HTTPException
import logging
logger = logging.getLogger(__name__)
class TwitterAPI:
def __init__(self, consumer_key: str, consumer_secret: str, access_token: str, access_token_secret: str):
"""
Инициализация Twitter API клиента
Args:
consumer_key: Ключ приложения Twitter
consumer_secret: Секрет приложения Twitter
access_token: Токен доступа
access_token_secret: Секрет токена доступа
"""
try:
# Создаем объект аутентификации
auth = tweepy.OAuthHandler(consumer_key, consumer_secret)
auth.set_access_token(access_token, access_token_secret)
# Создаем API клиент
self.api = tweepy.API(auth, wait_on_rate_limit=True)
# Проверяем валидность аутентификации
self.api.verify_credentials()
except Exception as e:
logger.error(f"Error initializing Twitter API: {str(e)}")
raise HTTPException(status_code=500, detail=f"Failed to initialize Twitter API: {str(e)}")
def post_tweet(self, text: str) -> Dict:
"""
Опубликовать твит
Args:
text: Текст твита (до 280 символов)
Returns:
Информация о созданном твите
"""
try:
# Публикуем твит
tweet = self.api.update_status(text)
return {
"success": True,
"tweet_id": tweet.id,
"tweet_text": tweet.text,
"created_at": tweet.created_at.isoformat() if tweet.created_at else None
}
except tweepy.TweepyException as e:
logger.error(f"Error posting tweet: {str(e)}")
raise HTTPException(status_code=500, detail=f"Failed to post tweet: {str(e)}")
except Exception as e:
logger.error(f"Unexpected error posting tweet: {str(e)}")
raise HTTPException(status_code=500, detail=f"Failed to post tweet: {str(e)}")
def post_thread(self, tweets: list) -> Dict:
"""
Опубликовать тред (цепочку твитов)
Args:
tweets: Список текстов твитов в порядке публикации
Returns:
Информация о созданном треде
"""
try:
previous_tweet_id = None
tweet_ids = []
for i, text in enumerate(tweets):
if previous_tweet_id:
# Отвечаем на предыдущий твит, чтобы создать тред
tweet = self.api.update_status(
status=text,
in_reply_to_status_id=previous_tweet_id,
auto_populate_reply_metadata=True
)
else:
# Первый твит в треде
tweet = self.api.update_status(text)
tweet_ids.append(tweet.id)
previous_tweet_id = tweet.id
return {
"success": True,
"tweet_ids": tweet_ids,
"thread_length": len(tweets)
}
except tweepy.TweepyException as e:
logger.error(f"Error posting thread: {str(e)}")
raise HTTPException(status_code=500, detail=f"Failed to post thread: {str(e)}")
except Exception as e:
logger.error(f"Unexpected error posting thread: {str(e)}")
raise HTTPException(status_code=500, detail=f"Failed to post thread: {str(e)}")
def get_user_info(self) -> Dict:
"""
Получить информацию о текущем пользователе
Returns:
Информация о пользователе
"""
try:
user = self.api.verify_credentials()
return {
"id": user.id,
"screen_name": user.screen_name,
"name": user.name,
"followers_count": user.followers_count,
"friends_count": user.friends_count,
"statuses_count": user.statuses_count,
"description": user.description
}
except tweepy.TweepyException as e:
logger.error(f"Error getting user info: {str(e)}")
raise HTTPException(status_code=500, detail=f"Failed to get user info: {str(e)}")
except Exception as e:
logger.error(f"Unexpected error getting user info: {str(e)}")
raise HTTPException(status_code=500, detail=f"Failed to get user info: {str(e)}")
+145
View File
@@ -4,6 +4,9 @@ from sqlalchemy import and_, or_
from ..models.post import Post from ..models.post import Post
from ..schemas.post import PostCreate, PostUpdate from ..schemas.post import PostCreate, PostUpdate
from datetime import datetime from datetime import datetime
from ..integrations.telegram_bot import TelegramBot
from ..integrations.twitter_api import TwitterAPI
import os
class PostService: class PostService:
@@ -86,3 +89,145 @@ class PostService:
return self.db.query(Post).filter( return self.db.query(Post).filter(
and_(Post.owner_id == owner_id, Post.status == status) and_(Post.owner_id == owner_id, Post.status == status)
).all() ).all()
def publish_post_to_telegram(self, post_id: int, chat_id: str) -> bool:
"""
Опубликовать пост в Telegram
Args:
post_id: ID поста в базе данных
chat_id: ID чата или канала в Telegram
Returns:
Успешность публикации
"""
# Получаем пост из базы данных
post = self.db.query(Post).filter(Post.id == post_id).first()
if not post:
return False
# Получаем токен бота из переменных окружения
bot_token = os.getenv("TELEGRAM_BOT_TOKEN")
if not bot_token:
raise ValueError("TELEGRAM_BOT_TOKEN environment variable is not set")
# Создаем экземпляр TelegramBot
bot = TelegramBot(bot_token)
try:
# Формируем сообщение из заголовка и содержимого поста
message = f"<b>{post.title}</b>\n\n{post.content}"
# Отправляем сообщение в Telegram
result = bot.send_message(chat_id=chat_id, text=message)
# Если публикация успешна, обновляем статус поста и время публикации
if result.get("ok"):
post.status = "published"
post.published_at = datetime.utcnow()
self.db.commit()
return True
else:
return False
except Exception as e:
print(f"Error publishing post to Telegram: {str(e)}")
return False
def publish_scheduled_posts(self, chat_id: str) -> int:
"""
Опубликовать все запланированные посты, время которых наступило
Args:
chat_id: ID чата или канала в Telegram
Returns:
Количество опубликованных постов
"""
scheduled_posts = self.get_scheduled_posts()
published_count = 0
for post in scheduled_posts:
try:
# Пытаемся опубликовать пост в Telegram
success = self.publish_post_to_telegram(post.id, chat_id)
if success:
published_count += 1
# Обновляем статус поста на published
post.status = "published"
post.published_at = datetime.utcnow()
self.db.commit()
else:
# Если публикация не удалась, помечаем пост как cancelled
post.status = "cancelled"
self.db.commit()
except Exception as e:
print(f"Error publishing scheduled post {post.id}: {str(e)}")
# Если возникла ошибка, помечаем пост как cancelled
post.status = "cancelled"
self.db.commit()
return published_count
def publish_post_to_twitter(self, post_id: int) -> bool:
"""
Опубликовать пост в Twitter
Args:
post_id: ID поста в базе данных
Returns:
Успешность публикации
"""
# Получаем пост из базы данных
post = self.db.query(Post).filter(Post.id == post_id).first()
if not post:
return False
# Получаем параметры для Twitter API из переменных окружения
twitter_consumer_key = os.getenv("TWITTER_CONSUMER_KEY")
twitter_consumer_secret = os.getenv("TWITTER_CONSUMER_SECRET")
twitter_access_token = os.getenv("TWITTER_ACCESS_TOKEN")
twitter_access_token_secret = os.getenv("TWITTER_ACCESS_TOKEN_SECRET")
if not all([twitter_consumer_key, twitter_consumer_secret, twitter_access_token, twitter_access_token_secret]):
raise ValueError("Twitter API credentials are not fully configured in environment variables")
# Создаем экземпляр TwitterAPI
twitter_api = TwitterAPI(
consumer_key=twitter_consumer_key,
consumer_secret=twitter_consumer_secret,
access_token=twitter_access_token,
access_token_secret=twitter_access_token_secret
)
try:
# Формируем текст твита из заголовка и содержимого поста
# Учитываем ограничение на 280 символов
combined_text = f"{post.title}\n\n{post.content}"
if len(combined_text) > 280:
# Обрезаем текст до 280 символов, возможно сокращая содержимое
truncated_title = post.title[:int(280*0.3)] # используем 30% на заголовок
remaining_chars = 280 - len(truncated_title) - 3 # -3 для "..."
if len(post.content) > remaining_chars:
combined_text = f"{truncated_title}...\n\n{post.content[:remaining_chars-4]}..."
else:
combined_text = f"{truncated_title}...\n\n{post.content}"
# Отправляем твит
result = twitter_api.post_tweet(combined_text)
# Если публикация успешна, обновляем статус поста и время публикации
if result.get("success"):
post.status = "published"
post.published_at = datetime.utcnow()
self.db.commit()
return True
else:
return False
except Exception as e:
print(f"Error publishing post to Twitter: {str(e)}")
return False
+2
View File
@@ -11,3 +11,5 @@ pydantic==2.5.0
pydantic-settings==2.1.0 pydantic-settings==2.1.0
redis==5.0.1 redis==5.0.1
cachetools==5.3.2 cachetools==5.3.2
python-telegram-bot==20.7
tweepy==4.14.0
+86
View File
@@ -0,0 +1,86 @@
import pytest
from fastapi.testclient import TestClient
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from app.main import app
from app.database.database import get_db
from app.models.post import Post
from app.models.user import User
from datetime import datetime
# Тестовый клиент
client = TestClient(app)
# Настройка тестовой базы данных
SQLALCHEMY_DATABASE_URL = "sqlite:///./test.db"
engine = create_engine(SQLALCHEMY_DATABASE_URL, connect_args={"check_same_thread": False})
TestingSessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
def override_get_db():
try:
db = TestingSessionLocal()
yield db
finally:
db.close()
app.dependency_overrides[get_db] = override_get_db
def test_create_post():
"""Тест создания поста"""
user_data = {
"username": "testuser",
"email": "test@example.com",
"full_name": "Test User"
}
# Создаем пользователя
response = client.post("/users/", json=user_data)
assert response.status_code == 200
# Создаем пост
post_data = {
"title": "Test Post",
"content": "This is a test post content",
"status": "draft"
}
response = client.post("/posts/", json=post_data)
assert response.status_code == 200
data = response.json()
assert data["title"] == "Test Post"
assert data["content"] == "This is a test post content"
return data["id"]
def test_publish_to_telegram():
"""Тест публикации в Telegram (без фактической отправки)"""
# Создаем пост для тестирования
post_id = test_create_post()
# Тестируем публикацию в Telegram
# Так как у нас нет реального токена в тестовой среде,
# мы ожидаем ошибку связанную с отсутствием настроек
response = client.post(f"/posts/{post_id}/publish_telegram?chat_id=test_chat")
# Ожидаем ошибку из-за отсутствия TELEGRAM_BOT_TOKEN
assert response.status_code in [422, 500]
def test_publish_to_twitter():
"""Тест публикации в Twitter (без фактической отправки)"""
# Создаем пост для тестирования
post_id = test_create_post()
# Тестируем публикацию в Twitter
# Так как у нас нет реальных ключей в тестовой среде,
# мы ожидаем ошибку связанную с отсутствием настроек
response = client.post(f"/posts/{post_id}/publish_twitter")
# Ожидаем ошибку из-за отсутствия TWITTER_* переменных окружения
assert response.status_code in [422, 500]
if __name__ == "__main__":
pytest.main()
@@ -11,6 +11,9 @@
let status = 'draft'; let status = 'draft';
let loading = false; let loading = false;
let error = null; let error = null;
let telegramPublishLoading = false;
let telegramChatId = '';
let twitterPublishLoading = false;
// Load post data if editing // Load post data if editing
onMount(async () => { onMount(async () => {
@@ -131,6 +134,97 @@
e.preventDefault(); e.preventDefault();
await savePost(); await savePost();
} }
async function publishToTelegram() {
if (!postId) {
error = 'Post must be saved before publishing to Telegram';
return;
}
if (!telegramChatId.trim()) {
error = 'Please enter a Telegram Chat ID';
return;
}
telegramPublishLoading = true;
error = null;
try {
const token = localStorage.getItem('token');
if (!token) {
throw new Error('No authentication token found');
}
const response = await fetch(`${import.meta.env.VITE_API_URL || '/api/v1'}/posts/${postId}/publish_telegram?chat_id=${encodeURIComponent(telegramChatId)}`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
}
});
if (!response.ok) {
if (response.status === 401) {
localStorage.removeItem('token');
goto('/login');
return;
}
const errorData = await response.json();
throw new Error(errorData.detail || 'Failed to publish post to Telegram');
}
const result = await response.json();
alert('Post published to Telegram successfully!');
} catch (err) {
console.error('Error publishing to Telegram:', err);
error = err.message;
} finally {
telegramPublishLoading = false;
}
}
async function publishToTwitter() {
if (!postId) {
error = 'Post must be saved before publishing to Twitter';
return;
}
twitterPublishLoading = true;
error = null;
try {
const token = localStorage.getItem('token');
if (!token) {
throw new Error('No authentication token found');
}
const response = await fetch(`${import.meta.env.VITE_API_URL || '/api/v1'}/posts/${postId}/publish_twitter`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
}
});
if (!response.ok) {
if (response.status === 401) {
localStorage.removeItem('token');
goto('/login');
return;
}
const errorData = await response.json();
throw new Error(errorData.detail || 'Failed to publish post to Twitter');
}
const result = await response.json();
alert('Post published to Twitter successfully!');
} catch (err) {
console.error('Error publishing to Twitter:', err);
error = err.message;
} finally {
twitterPublishLoading = false;
}
}
</script> </script>
<div class="post-editor"> <div class="post-editor">
@@ -193,6 +287,32 @@
Cancel Cancel
</button> </button>
</div> </div>
{#if postId}
<div class="telegram-publish-section">
<h3>Publish to Telegram</h3>
<div class="form-group">
<label for="telegram-chat-id">Telegram Chat ID:</label>
<input
id="telegram-chat-id"
type="text"
bind:value={telegramChatId}
placeholder="Enter Telegram Chat ID or @channel_name"
disabled={telegramPublishLoading}
/>
</div>
<button type="button" on:click={publishToTelegram} disabled={telegramPublishLoading || loading}>
{telegramPublishLoading ? 'Publishing...' : 'Publish to Telegram'}
</button>
</div>
<div class="twitter-publish-section">
<h3>Publish to Twitter</h3>
<button type="button" on:click={publishToTwitter} disabled={twitterPublishLoading || loading}>
{twitterPublishLoading ? 'Publishing...' : 'Publish to Twitter'}
</button>
</div>
{/if}
</form> </form>
</div> </div>
@@ -269,4 +389,26 @@
border-radius: 4px; border-radius: 4px;
margin-bottom: 1rem; margin-bottom: 1rem;
} }
.telegram-publish-section {
margin-top: 2rem;
padding-top: 1.5rem;
border-top: 1px solid #eee;
}
.telegram-publish-section h3 {
margin-bottom: 1rem;
color: #333;
}
.twitter-publish-section {
margin-top: 2rem;
padding-top: 1.5rem;
border-top: 1px solid #eee;
}
.twitter-publish-section h3 {
margin-bottom: 1rem;
color: #333;
}
</style> </style>
+6
View File
@@ -0,0 +1,6 @@
{
"name": "telepost",
"lockfileVersion": 3,
"requires": true,
"packages": {}
}