355 lines
13 KiB
Python
355 lines
13 KiB
Python
|
|
import os
|
|||
|
|
import requests
|
|||
|
|
import logging
|
|||
|
|
import time
|
|||
|
|
from pathlib import Path
|
|||
|
|
from dotenv import load_dotenv
|
|||
|
|
|
|||
|
|
# Инициализация путей
|
|||
|
|
BASE_DIR = Path(__file__).parent
|
|||
|
|
DATA_DIR = BASE_DIR / "data"
|
|||
|
|
LOG_DIR = BASE_DIR / "logs"
|
|||
|
|
|
|||
|
|
# Создание директорий
|
|||
|
|
DATA_DIR.mkdir(exist_ok=True, parents=True)
|
|||
|
|
LOG_DIR.mkdir(exist_ok=True, parents=True)
|
|||
|
|
|
|||
|
|
# Загрузка переменных окружения
|
|||
|
|
load_dotenv()
|
|||
|
|
|
|||
|
|
# Конфигурация
|
|||
|
|
TELEGRAM_TOKEN = os.getenv('TELEGRAM_BOT_TOKEN')
|
|||
|
|
TELEGRAM_CHANNEL = os.getenv('TELEGRAM_CHANNEL')
|
|||
|
|
VK_TOKEN = os.getenv('VK_ACCESS_TOKEN')
|
|||
|
|
VK_GROUP_ID = int(os.getenv('VK_GROUP_ID', '-14409962'))
|
|||
|
|
CHECK_INTERVAL = int(os.getenv('CHECK_INTERVAL', '180'))
|
|||
|
|
|
|||
|
|
# Логирование
|
|||
|
|
logging.basicConfig(
|
|||
|
|
level=logging.INFO,
|
|||
|
|
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
|
|||
|
|
handlers=[
|
|||
|
|
logging.FileHandler(LOG_DIR / 'vk_crosspost.log'),
|
|||
|
|
logging.StreamHandler()
|
|||
|
|
]
|
|||
|
|
)
|
|||
|
|
logger = logging.getLogger('VK_CrossPost')
|
|||
|
|
|
|||
|
|
class TelegramToVK:
|
|||
|
|
def __init__(self):
|
|||
|
|
self.MAX_ATTACHMENTS = 10
|
|||
|
|
self.LAST_ID_FILE = DATA_DIR / "last_id.txt"
|
|||
|
|
self.last_id = self._load_last_id()
|
|||
|
|
logger.info(f"Bot initialized. Last processed ID: {self.last_id}")
|
|||
|
|
|
|||
|
|
def _load_last_id(self):
|
|||
|
|
"""Загрузка последнего ID с созданием файла при необходимости"""
|
|||
|
|
try:
|
|||
|
|
if self.LAST_ID_FILE.exists():
|
|||
|
|
with open(self.LAST_ID_FILE, 'r') as f:
|
|||
|
|
content = f.read().strip()
|
|||
|
|
if content.isdigit():
|
|||
|
|
return int(content)
|
|||
|
|
|
|||
|
|
# Если файла нет или он пуст/поврежден
|
|||
|
|
last_id = self._fetch_last_post_id()
|
|||
|
|
self._save_last_id(last_id)
|
|||
|
|
return last_id
|
|||
|
|
|
|||
|
|
except Exception as e:
|
|||
|
|
logger.error(f"Error loading last_id: {e}")
|
|||
|
|
return 0
|
|||
|
|
|
|||
|
|
def _fetch_last_post_id(self):
|
|||
|
|
"""Получение последнего ID поста из Telegram"""
|
|||
|
|
try:
|
|||
|
|
response = requests.get(
|
|||
|
|
f'https://api.telegram.org/bot{TELEGRAM_TOKEN}/getUpdates',
|
|||
|
|
params={'limit': 1},
|
|||
|
|
timeout=10
|
|||
|
|
).json()
|
|||
|
|
|
|||
|
|
if response.get('result'):
|
|||
|
|
return response['result'][-1]['update_id']
|
|||
|
|
return 0
|
|||
|
|
except Exception as e:
|
|||
|
|
logger.error(f"Error fetching last post ID: {e}")
|
|||
|
|
return 0
|
|||
|
|
|
|||
|
|
def _save_last_id(self, last_id):
|
|||
|
|
"""Атомарное сохранение ID последнего поста"""
|
|||
|
|
try:
|
|||
|
|
temp_file = self.LAST_ID_FILE.with_suffix('.tmp')
|
|||
|
|
with open(temp_file, 'w') as f:
|
|||
|
|
f.write(str(last_id))
|
|||
|
|
temp_file.replace(self.LAST_ID_FILE)
|
|||
|
|
except Exception as e:
|
|||
|
|
logger.error(f"Failed to save last_id: {e}")
|
|||
|
|
|
|||
|
|
def _get_telegram_updates(self):
|
|||
|
|
"""Получение новых постов"""
|
|||
|
|
try:
|
|||
|
|
response = requests.get(
|
|||
|
|
f'https://api.telegram.org/bot{TELEGRAM_TOKEN}/getUpdates',
|
|||
|
|
params={
|
|||
|
|
'offset': self.last_id + 1,
|
|||
|
|
'allowed_updates': ['channel_post'],
|
|||
|
|
'timeout': 30
|
|||
|
|
},
|
|||
|
|
timeout=35
|
|||
|
|
).json()
|
|||
|
|
|
|||
|
|
if not response.get('ok'):
|
|||
|
|
logger.error(f"Telegram API error: {response.get('description')}")
|
|||
|
|
return []
|
|||
|
|
|
|||
|
|
return response.get('result', [])
|
|||
|
|
except Exception as e:
|
|||
|
|
logger.error(f"Error getting updates: {e}")
|
|||
|
|
return []
|
|||
|
|
|
|||
|
|
def _process_media(self, post):
|
|||
|
|
"""Обработка медиа без дублирования"""
|
|||
|
|
attachments = []
|
|||
|
|
|
|||
|
|
# Обработка видео
|
|||
|
|
if 'video' in post:
|
|||
|
|
video = post['video']
|
|||
|
|
if attachment := self._upload_media(video['file_id'], 'video'):
|
|||
|
|
attachments.append(attachment)
|
|||
|
|
logger.debug("Added video attachment")
|
|||
|
|
|
|||
|
|
# Обработка фото (только самое качественное)
|
|||
|
|
if 'photo' in post and len(post['photo']) > 0:
|
|||
|
|
best_photo = post['photo'][-1]
|
|||
|
|
if attachment := self._upload_media(best_photo['file_id'], 'photo'):
|
|||
|
|
attachments.append(attachment)
|
|||
|
|
logger.debug(f"Added photo (best of {len(post['photo'])} versions)")
|
|||
|
|
|
|||
|
|
return attachments[:self.MAX_ATTACHMENTS]
|
|||
|
|
|
|||
|
|
def _upload_media(self, file_id, media_type):
|
|||
|
|
"""Загрузка медиа в VK"""
|
|||
|
|
try:
|
|||
|
|
file_url = self._get_telegram_file_url(file_id)
|
|||
|
|
if not file_url:
|
|||
|
|
return None
|
|||
|
|
|
|||
|
|
if media_type == 'photo':
|
|||
|
|
# Получение URL для загрузки фото
|
|||
|
|
upload_server = requests.get(
|
|||
|
|
'https://api.vk.com/method/photos.getWallUploadServer',
|
|||
|
|
params={
|
|||
|
|
'group_id': abs(VK_GROUP_ID),
|
|||
|
|
'access_token': VK_TOKEN,
|
|||
|
|
'v': '5.131'
|
|||
|
|
},
|
|||
|
|
timeout=10
|
|||
|
|
).json()
|
|||
|
|
|
|||
|
|
if 'error' in upload_server:
|
|||
|
|
logger.error(f"VK upload server error: {upload_server['error']}")
|
|||
|
|
return None
|
|||
|
|
|
|||
|
|
# Загрузка фото
|
|||
|
|
file_data = requests.get(file_url, timeout=10).content
|
|||
|
|
upload = requests.post(
|
|||
|
|
upload_server['response']['upload_url'],
|
|||
|
|
files={'photo': ('image.jpg', file_data)},
|
|||
|
|
timeout=15
|
|||
|
|
).json()
|
|||
|
|
|
|||
|
|
# Сохранение фото
|
|||
|
|
save_response = requests.get(
|
|||
|
|
'https://api.vk.com/method/photos.saveWallPhoto',
|
|||
|
|
params={
|
|||
|
|
'group_id': abs(VK_GROUP_ID),
|
|||
|
|
'photo': upload['photo'],
|
|||
|
|
'server': upload['server'],
|
|||
|
|
'hash': upload['hash'],
|
|||
|
|
'access_token': VK_TOKEN,
|
|||
|
|
'v': '5.131'
|
|||
|
|
},
|
|||
|
|
timeout=10
|
|||
|
|
).json()
|
|||
|
|
|
|||
|
|
if 'error' in save_response:
|
|||
|
|
logger.error(f"VK save photo error: {save_response['error']}")
|
|||
|
|
return None
|
|||
|
|
|
|||
|
|
photo = save_response['response'][0]
|
|||
|
|
return f"photo{photo['owner_id']}_{photo['id']}"
|
|||
|
|
|
|||
|
|
elif media_type == 'video':
|
|||
|
|
# Загрузка видео
|
|||
|
|
upload_server = requests.get(
|
|||
|
|
'https://api.vk.com/method/video.save',
|
|||
|
|
params={
|
|||
|
|
'name': 'Video from Telegram',
|
|||
|
|
'group_id': abs(VK_GROUP_ID),
|
|||
|
|
'access_token': VK_TOKEN,
|
|||
|
|
'v': '5.131'
|
|||
|
|
},
|
|||
|
|
timeout=10
|
|||
|
|
).json()
|
|||
|
|
|
|||
|
|
if 'error' in upload_server:
|
|||
|
|
logger.error(f"VK video save error: {upload_server['error']}")
|
|||
|
|
return None
|
|||
|
|
|
|||
|
|
file_data = requests.get(file_url, timeout=15).content
|
|||
|
|
upload = requests.post(
|
|||
|
|
upload_server['response']['upload_url'],
|
|||
|
|
files={'video_file': ('video.mp4', file_data)},
|
|||
|
|
timeout=20
|
|||
|
|
).json()
|
|||
|
|
|
|||
|
|
return f"video{upload['owner_id']}_{upload['video_id']}"
|
|||
|
|
|
|||
|
|
except Exception as e:
|
|||
|
|
logger.error(f"Error uploading {media_type}: {e}")
|
|||
|
|
|
|||
|
|
return None
|
|||
|
|
|
|||
|
|
def _get_telegram_file_url(self, file_id):
|
|||
|
|
"""Получение URL файла из Telegram"""
|
|||
|
|
try:
|
|||
|
|
response = requests.get(
|
|||
|
|
f'https://api.telegram.org/bot{TELEGRAM_TOKEN}/getFile',
|
|||
|
|
params={'file_id': file_id},
|
|||
|
|
timeout=10
|
|||
|
|
).json()
|
|||
|
|
|
|||
|
|
if response.get('ok'):
|
|||
|
|
return f"https://api.telegram.org/file/bot{TELEGRAM_TOKEN}/{response['result']['file_path']}"
|
|||
|
|
logger.error(f"Telegram file error: {response.get('description')}")
|
|||
|
|
except Exception as e:
|
|||
|
|
logger.error(f"Error getting file URL: {e}")
|
|||
|
|
|
|||
|
|
return None
|
|||
|
|
|
|||
|
|
def _post_to_vk(self, text, attachments):
|
|||
|
|
"""Публикация поста в VK"""
|
|||
|
|
if not text and not attachments:
|
|||
|
|
logger.warning("Skipping empty post")
|
|||
|
|
return False
|
|||
|
|
|
|||
|
|
params = {
|
|||
|
|
'owner_id': VK_GROUP_ID,
|
|||
|
|
'from_group': 1,
|
|||
|
|
'access_token': VK_TOKEN,
|
|||
|
|
'v': '5.131'
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if text:
|
|||
|
|
params['message'] = text[:1000] # Лимит VK
|
|||
|
|
logger.debug(f"Post text: {text[:50]}...")
|
|||
|
|
|
|||
|
|
if attachments:
|
|||
|
|
params['attachments'] = ','.join(attachments[:10]) # Лимит VK
|
|||
|
|
logger.debug(f"Attachments: {len(attachments)} items")
|
|||
|
|
|
|||
|
|
try:
|
|||
|
|
response = requests.post(
|
|||
|
|
'https://api.vk.com/method/wall.post',
|
|||
|
|
params=params,
|
|||
|
|
timeout=15
|
|||
|
|
).json()
|
|||
|
|
|
|||
|
|
if 'error' in response:
|
|||
|
|
logger.error(f"VK API error: {response['error']}")
|
|||
|
|
return False
|
|||
|
|
|
|||
|
|
post_id = response['response']['post_id']
|
|||
|
|
logger.info(f"Posted: https://vk.com/wall{VK_GROUP_ID}_{post_id}")
|
|||
|
|
return True
|
|||
|
|
|
|||
|
|
except Exception as e:
|
|||
|
|
logger.error(f"Error posting to VK: {e}")
|
|||
|
|
|
|||
|
|
return False
|
|||
|
|
|
|||
|
|
def run(self):
|
|||
|
|
"""Основной цикл работы"""
|
|||
|
|
logger.info("Starting Telegram to VK crossposting bot...")
|
|||
|
|
try:
|
|||
|
|
while True:
|
|||
|
|
self._process_new_posts()
|
|||
|
|
logger.debug(f"Waiting {CHECK_INTERVAL} seconds...")
|
|||
|
|
time.sleep(CHECK_INTERVAL)
|
|||
|
|
except KeyboardInterrupt:
|
|||
|
|
logger.info("Bot stopped by user")
|
|||
|
|
except Exception as e:
|
|||
|
|
logger.critical(f"Fatal error: {e}", exc_info=True)
|
|||
|
|
raise
|
|||
|
|
|
|||
|
|
def _process_new_posts(self):
|
|||
|
|
updates = self._get_telegram_updates()
|
|||
|
|
if not updates:
|
|||
|
|
logger.debug("No new updates")
|
|||
|
|
return
|
|||
|
|
|
|||
|
|
logger.info(f"Found {len(updates)} new updates")
|
|||
|
|
|
|||
|
|
# Группировка по media_group_id (альбом) или update_id (одиночный пост)
|
|||
|
|
grouped_posts = {}
|
|||
|
|
for update in sorted(updates, key=lambda x: x['update_id']):
|
|||
|
|
current_id = update['update_id']
|
|||
|
|
if current_id <= self.last_id:
|
|||
|
|
continue
|
|||
|
|
|
|||
|
|
post = update.get('channel_post')
|
|||
|
|
if not post:
|
|||
|
|
continue
|
|||
|
|
|
|||
|
|
group_id = post.get('media_group_id', current_id) # альбом или одиночка
|
|||
|
|
grouped_posts.setdefault(group_id, []).append((current_id, post))
|
|||
|
|
|
|||
|
|
# Обработка каждой группы постов
|
|||
|
|
for group_id, posts in grouped_posts.items():
|
|||
|
|
posts.sort(key=lambda x: x[0]) # сортировка внутри группы по update_id
|
|||
|
|
|
|||
|
|
# Текст берём из первого поста группы
|
|||
|
|
first_post = posts[0][1]
|
|||
|
|
text = first_post.get('text') or first_post.get('caption') or ""
|
|||
|
|
|
|||
|
|
attachments = []
|
|||
|
|
|
|||
|
|
for _, post in posts:
|
|||
|
|
# Фото (берём самое качественное из набора размеров)
|
|||
|
|
photos = post.get('photo') or []
|
|||
|
|
if photos:
|
|||
|
|
best_photo = photos[-1]
|
|||
|
|
att = self._upload_media(best_photo['file_id'], 'photo')
|
|||
|
|
if att:
|
|||
|
|
attachments.append(att)
|
|||
|
|
|
|||
|
|
# Видео
|
|||
|
|
if 'video' in post:
|
|||
|
|
att = self._upload_media(post['video']['file_id'], 'video')
|
|||
|
|
if att:
|
|||
|
|
attachments.append(att)
|
|||
|
|
|
|||
|
|
# Ограничим количество вложений (и так в _post_to_vk есть [:10], но пусть будет явно)
|
|||
|
|
if attachments:
|
|||
|
|
attachments = attachments[:self.MAX_ATTACHMENTS]
|
|||
|
|
|
|||
|
|
# Публикуем, если есть текст ИЛИ вложения.
|
|||
|
|
if text or attachments:
|
|||
|
|
if self._post_to_vk(text, attachments):
|
|||
|
|
self.last_id = posts[-1][0] # помечаем обработанным последний update из группы
|
|||
|
|
self._save_last_id(self.last_id)
|
|||
|
|
logger.info(f"Processed group {group_id} (text={'yes' if text else 'no'}, attachments={len(attachments)})")
|
|||
|
|
else:
|
|||
|
|
logger.error(f"Failed to post group {group_id}")
|
|||
|
|
else:
|
|||
|
|
# Совсем пустая группа (редко, но бывает) — помечаем как обработанную, чтобы не зациклиться
|
|||
|
|
self.last_id = posts[-1][0]
|
|||
|
|
self._save_last_id(self.last_id)
|
|||
|
|
logger.info(f"Skipped empty group {group_id}: no text and no media")
|
|||
|
|
|
|||
|
|
|
|||
|
|
|
|||
|
|
if __name__ == '__main__':
|
|||
|
|
bot = TelegramToVK()
|
|||
|
|
bot.run()
|