diff --git a/Dockerfile b/Dockerfile
index 7dddc7e..efab6d4 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -6,4 +6,4 @@ COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY *.py .
-CMD ["uvicorn", "api_server:app", "--host", "0.0.0.0", "--port", "8000"]
\ No newline at end of file
+CMD ["python", "api_server.py"]
\ No newline at end of file
diff --git a/api_server.py b/api_server.py
index b779a18..1f963a0 100644
--- a/api_server.py
+++ b/api_server.py
@@ -1,173 +1,113 @@
import logging
-import json
-import re
+import os
+import mimetypes
from contextlib import asynccontextmanager
from fastapi import FastAPI, HTTPException, Response, BackgroundTasks
from fastapi.responses import HTMLResponse, FileResponse, PlainTextResponse
from telegram_client import TelegramClient
from config import get_settings
-import mimetypes
-import os
+from rss_generator import generate_channel_rss
from pyrogram import errors
-from feedgen.feed import FeedGenerator
+from post_parser import PostParser
logger = logging.getLogger(__name__)
client = TelegramClient()
-settings = get_settings()
+Config = get_settings()
@asynccontextmanager
async def lifespan(_: FastAPI):
- # Startup
await client.start()
yield
- # Shutdown
await client.stop()
-app = FastAPI(
- title="PyroTg Bridge",
- lifespan=lifespan
-)
+app = FastAPI( title="Pyrogram Bridge", lifespan=lifespan)
if __name__ == "__main__":
import uvicorn
- uvicorn.run( "api_server:app", host=settings["api_host"], port=settings["api_port"], reload=False)
+ uvicorn.run("api_server:app", host=Config["api_host"], port=Config["api_port"], reload=True)
@app.get("/html/{channel}/{post_id}", response_class=HTMLResponse)
+@app.get("/post/html/{channel}/{post_id}", response_class=HTMLResponse)
async def get_post_html(channel: str, post_id: int):
try:
- post = await client.get_post(channel, post_id)
- if post.get("error"):
- raise HTTPException(
- status_code=404,
- detail=post["details"]
- )
- if "error" in post:
- raise HTTPException(status_code=404, detail=post["details"])
- return post["html"]
+ parser = PostParser(client.client)
+ html_content = await parser.get_post(channel, post_id, 'html')
+ if not html_content:
+ raise HTTPException(status_code=404, detail="Post not found")
+ return html_content
except Exception as e:
- logger.error(f"HTML endpoint error: {str(e)}")
- raise HTTPException(status_code=500, detail=str(e)) from e
+ error_message = f"Failed to get HTML post for channel {channel}, post_id {post_id}: {str(e)}"
+ logger.error(error_message)
+ raise HTTPException(status_code=500, detail=error_message) from e
+
@app.get("/json/{channel}/{post_id}")
+@app.get("/post/json/{channel}/{post_id}")
async def get_post(channel: str, post_id: int):
try:
- data = await client.get_post(channel, post_id)
- if data.get("error"):
- raise HTTPException(
- status_code=404,
- detail=data
- )
- return Response(
- content=json.dumps(data),
- media_type="application/json"
- )
+ parser = PostParser(client.client)
+ json_content = await parser.get_post(channel, post_id, 'json')
+ if not json_content:
+ raise HTTPException(status_code=404, detail="Post not found")
+ return json_content
except Exception as e:
- logger.error(f"API error: {str(e)}")
- raise HTTPException(
- status_code=404,
- detail={
- "error": "post_retrieval_error",
- "message": str(e)
- }
- ) from e
+ error_message = f"Failed to get JSON post for channel {channel}, post_id {post_id}: {str(e)}"
+ logger.error(error_message)
+ raise HTTPException(status_code=500, detail=error_message) from e
-@app.get("/status")
+@app.get("/health")
async def health_check():
- return {
- "status": "ok",
- "connected": client.client.is_connected
- }
+ try:
+ me = await client.client.get_me()
+ return {
+ "status": "ok",
+ "tg_connected": client.client.is_connected,
+ "tg_name": me.username,
+ "tg_id": me.id,
+ "tg_phone": me.phone_number,
+ "tg_first_name": me.first_name,
+ "tg_last_name": me.last_name,
+ }
+ except Exception as e:
+ error_message = f"Failed to get health check: {str(e)}"
+ logger.error(error_message)
+ raise HTTPException(status_code=500, detail=error_message) from e
@app.get("/media/{file_id}")
-async def get_media(
- file_id: str,
- background_tasks: BackgroundTasks,
- response: Response
-):
+async def get_media(file_id: str, background_tasks: BackgroundTasks):
try:
- file_path = await client.download_media_file(file_id)
+ file_path = await client.download_media_file(file_id)
+ logger.info(f"Downloaded media file {file_id} to {file_path}")
+ if not os.path.exists(file_path): raise HTTPException(status_code=404, detail="Temporary file not found")
- # Additional safety checks
- if not os.path.exists(file_path):
- raise HTTPException(status_code=404, detail="Temporary file not found")
+ media_type, _ = mimetypes.guess_type(file_path) # Determine media type from file extension
+ if not media_type: media_type = "application/octet-stream"
- # Determine media type from file extension
- media_type, _ = mimetypes.guess_type(file_path)
- if not media_type:
- media_type = "application/octet-stream"
-
- # Add cleanup task
background_tasks.add_task(lambda: os.remove(file_path) if os.path.exists(file_path) else None)
- return FileResponse(
- path=file_path,
- media_type=media_type,
- headers={
- "Content-Disposition": f"inline; filename={os.path.basename(file_path)}"
- }
- )
-
+ headers = { "Content-Disposition": f"inline; filename={os.path.basename(file_path)}" }
+ return FileResponse( path=file_path, media_type=media_type, headers=headers)
except HTTPException:
- # Пробрасываем уже сформированные HTTPException как есть
raise
except errors.RPCError as e:
logger.error(f"Media request RPC error {file_id}: {type(e).__name__} - {str(e)}")
- raise HTTPException(status_code=404, detail="File not found in Telegram")
+ raise HTTPException(status_code=404, detail="File not found in Telegram") from e
except Exception as e:
- logger.error(f"Media request failed {file_id}: {type(e).__name__} - {str(e)}")
- raise HTTPException(
- status_code=500,
- detail=f"Internal server error: {str(e)}"
- )
+ error_message = f"Failed to get media for file_id {file_id}: {str(e)}"
+ logger.error(error_message)
+ raise HTTPException(status_code=500, detail=error_message) from e
-@app.get("/rss/{channel}", response_class=PlainTextResponse)
-async def get_rss_feed(channel: str):
+@app.get("/rss/{channel}", response_class=Response)
+async def get_rss_feed(channel: str, limit: int = 20):
try:
- # Get last 20 posts
- posts = await client.get_channel_posts(channel, limit=20)
-
- # Create FeedGenerator
- fg = FeedGenerator()
- channel_title = posts[0].get('channel_title', channel) if posts else channel
- channel_icon = posts[0].get('channel_icon', '') if posts else ''
-
- fg.title(channel_title)
- fg.link(href=f"https://t.me/{channel}", rel='alternate')
- fg.description(f'Telegram channel {channel} RSS feed')
- fg.language('ru-ru')
-
- if channel_icon:
- fg.logo(f"{settings['pyrogram_bridge_url']}/media/{channel_icon}")
- fg.icon(f"{settings['pyrogram_bridge_url']}/media/{channel_icon}")
-
- # Add channel metadata
- fg.id(f"{settings['pyrogram_bridge_url']}/rss/{channel}")
- fg.link(href=f"{settings['pyrogram_bridge_url']}/rss/{channel}", rel='self', type='application/rss+xml')
-
- # Add posts
- for post in posts:
- fe = fg.add_entry()
- fe.id(f"{settings['pyrogram_bridge_url']}/html/{channel}/{post['id']}")
- fe.title(post['title'])
- fe.link(href=f"https://t.me/{channel}/{post['id']}")
- fe.pubDate(post['date'].astimezone(tz=None))
- fe.description(f"")
- fe.content(content=post['html'], type='CDATA')
-
- # Add media enclosures
- for media in post.get('media', []):
- if media.get('url'):
- fe.enclosure(
- url=f"{settings['pyrogram_bridge_url']}/media/{media['url']}",
- type=media.get('type', 'image/jpeg'),
- length=str(media.get('size', 0))
- )
-
- # Generate RSS
- rss = fg.rss_str(pretty=True)
- return Response(content=rss, media_type="application/xml")
-
+ rss_content = await generate_channel_rss(channel, client=client.client, limit=limit)
+ return Response(content=rss_content, media_type="application/xml")
+ except ValueError as e:
+ error_message = f"Invalid parameters for RSS feed generation: {str(e)}"
+ logger.error(error_message)
+ raise HTTPException(status_code=400, detail=error_message) from e
except Exception as e:
- logger.error(f"RSS generation error: {str(e)}")
- raise HTTPException(status_code=500, detail="RSS feed generation failed")
\ No newline at end of file
+ error_message = f"Failed to generate RSS feed for channel {channel}: {str(e)}"
+ logger.error(error_message)
+ raise HTTPException(status_code=500, detail=error_message) from e
diff --git a/config.py b/config.py
index 8e8a3bb..30dd9ea 100644
--- a/config.py
+++ b/config.py
@@ -1,22 +1,14 @@
import os
-TG_API_ID = int(os.getenv("TG_API_ID"))
-TG_API_HASH = os.getenv("TG_API_HASH")
-SESSION_PATH = os.getenv("SESSION_PATH", "session.file") or "session.file"
-API_HOST = os.getenv("API_HOST", "0.0.0.0")
-API_PORT = int(os.getenv("API_PORT") or 8000)
-REQUEST_TIMEOUT = int(os.getenv("REQUEST_TIMEOUT") or 30)
-SESSION_STRING = os.getenv("TG_SESSION_STRING", "")
-
def get_settings():
return {
- "tg_api_id": TG_API_ID,
- "tg_api_hash": TG_API_HASH,
- "session_path": SESSION_PATH,
- "api_host": API_HOST,
- "api_port": API_PORT,
- "request_timeout": REQUEST_TIMEOUT,
- "session_string": SESSION_STRING,
+ "tg_api_id": int(os.getenv("TG_API_ID")),
+ "tg_api_hash": os.getenv("TG_API_HASH"),
+ "session_path": os.getenv("SESSION_PATH", "session.file") or "session.file",
+ "api_host": os.getenv("API_HOST", "0.0.0.0"),
+ "api_port": int(os.getenv("API_PORT") or 8000),
+ "session_string": os.getenv("TG_SESSION_STRING", ""),
"pyrogram_bridge_url": os.getenv("PYROGRAM_BRIDGE_URL", ""),
- "log_level": os.getenv("LOG_LEVEL", "INFO")
- }
\ No newline at end of file
+ "log_level": os.getenv("LOG_LEVEL", "INFO"),
+ "debug": os.getenv("DEBUG", "False") == "True"
+ }
diff --git a/post_parser.py b/post_parser.py
new file mode 100644
index 0000000..b087c6c
--- /dev/null
+++ b/post_parser.py
@@ -0,0 +1,283 @@
+import logging
+import copy
+import re
+from datetime import datetime
+from typing import Union, Dict, Any, List, Optional
+from pyrogram.types import Message
+from pyrogram.enums import MessageMediaType
+from config import get_settings
+
+Config = get_settings()
+
+logger = logging.getLogger(__name__)
+
+#tests
+#http://127.0.0.1:8000/post/html/DragorWW_space/114 — video
+#http://127.0.0.1:8000/post/html/DragorWW_space/20 - many photos
+#http://127.0.0.1:8000/post/html/DragorWW_space/58 - photos+video
+#http://127.0.0.1:8000/post/html/DragorWW_space/44 - poll
+#http://127.0.0.1:8000/post/html/DragorWW_space/46 - photo
+#http://127.0.0.1:8000/post/html/DragorWW_space/49, http://127.0.0.1:8000/post/html/DragorWW_space/63 — webpage
+#http://127.0.0.1:8000/post/html/deckru/826 - animation
+#http://127.0.0.1:8000/post/html/DragorWW_space/61 — links
+#http://127.0.0.1:8000/post/html/theyforcedme/3577 - video note
+#http://127.0.0.1:8000/post/html/theyforcedme/3572 - audio
+#http://127.0.0.1:8000/post/html/theyforcedme/3558 audio-note
+#http://127.0.0.1:8000/html/vvzvlad_lytdybr/426 - sticker
+
+class PostParser:
+ def __init__(self, client):
+ self.client = client
+
+ def _debug_message(self, message: Message) -> Message:
+ if Config["debug"]:
+ debug_message = copy.deepcopy(message)
+ debug_message.sender_chat = None
+ debug_message.caption_entities = None
+ debug_message.reactions = None
+ debug_message.entities = None
+ print(debug_message)
+ return
+
+ async def get_post(self, channel: str, post_id: int, output_type: str = 'json') -> Union[str, Dict[Any, Any]]:
+ try:
+ message = await self.client.get_messages(channel, post_id)
+
+ self._debug_message(message)
+
+ if not message:
+ logger.error(f"post_not_found: channel {channel}, post_id {post_id}")
+ return None
+
+ if output_type == 'html':
+ return self._format_html(message)
+ elif output_type == 'json':
+ return self._format_json(message)
+ else:
+ logger.error(f"Invalid output type: {output_type}")
+ return None
+
+ except Exception as e:
+ logger.error(f"post_parsing_error: channel {channel}, post_id {post_id}, error {str(e)}")
+ raise
+
+ def _format_json(self, message: Message, naked: bool = False) -> Dict[Any, Any]:
+ html_content = self._format_html(message, naked=naked)
+ result = {
+ 'channel': message.chat.username,
+ 'message_id': message.id,
+ 'date': datetime.timestamp(message.date),
+ 'text': message.text or message.caption or '',
+ 'html': html_content,
+ 'title': self._generate_title(message),
+ 'author': self._get_author_info(message),
+ 'views': message.views,
+ }
+
+ if message.media_group_id:
+ result['media_group_id'] = message.media_group_id
+
+ return result
+
+ def _get_author_info(self, message: Message) -> str:
+ if message.sender_chat:
+ title = getattr(message.sender_chat, 'title', '').strip()
+ username = getattr(message.sender_chat, 'username', '').strip()
+ return f"{title} by @{username}" if username else title
+ elif message.from_user:
+ first = getattr(message.from_user, 'first_name', '').strip()
+ last = getattr(message.from_user, 'last_name', '').strip()
+ username = getattr(message.from_user, 'username', '').strip()
+ name = ' '.join(filter(None, [first, last]))
+ return f"{name} by @{username}" if username else name
+ return "Unknown author"
+
+ def _generate_title(self, message: Message) -> str:
+ text = message.text or message.caption or ''
+ if not text:
+ if message.media == MessageMediaType.PHOTO: return "📷 Photo"
+ elif message.media == MessageMediaType.VIDEO: return "🎥 Video"
+ elif message.media == MessageMediaType.ANIMATION: return "🎞 GIF"
+ elif message.media == MessageMediaType.AUDIO: return "🎵 Audio"
+ elif message.media == MessageMediaType.VOICE: return "🎤 Voice message"
+ elif message.media == MessageMediaType.VIDEO_NOTE: return "🎥 Video message"
+ elif message.media == MessageMediaType.STICKER: return "🎯 Sticker"
+ return "📷 Media post"
+
+ # Remove URLs
+ text = re.sub(r'http[s]?://(?:[a-zA-Z]|[0-9]|[$-_@.&+]|[!*\\(\\),]|(?:%[0-9a-fA-F][0-9a-fA-F]))+', '', text)
+ # Remove HTML tags
+ text = re.sub('<[^<]+?>', '', text)
+ # Remove multiple spaces and empty lines
+ text = '\n'.join(line.strip() for line in text.split('\n') if line.strip())
+
+ # Get first non-empty line
+ first_line = text.split('\n')[0] if text else ""
+
+ max_length = 100
+ if len(first_line) <= max_length:
+ return first_line.strip()
+
+ # Cut to last space
+ trimmed = first_line[:max_length]
+ last_space = trimmed.rfind(' ')
+ if last_space > 0:
+ trimmed = trimmed[:last_space]
+
+ return f"{trimmed.strip()}..." if trimmed else ""
+
+ def _format_html(self, message: Message, naked: bool = False) -> str:
+ text = message.text.html if message.text else message.caption.html if message.caption else ''
+ text = text.replace('\n', '
')
+ html_content = []
+
+ if poll := getattr(message, "poll", None): # Poll formatting
+ if poll_html := self._format_poll(poll):
+ html_content.append(poll_html)
+
+ base_url = Config['pyrogram_bridge_url']
+ if message.media:
+ file_id = self._get_file_id(message)
+ if file_id:
+ html_content.append(f'