diff --git a/requirements.txt b/requirements.txt index 1b45bc2..1aa6b55 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,5 +3,4 @@ aiohttp==3.12.13 aiosqlite==0.21.0 loguru==0.7.3 pydantic-settings==2.10.1 -Pillow==11.3.0 yt-dlp>=2026.1.1 diff --git a/src/config.py b/src/config.py index 979d61a..790d772 100644 --- a/src/config.py +++ b/src/config.py @@ -26,8 +26,9 @@ class Settings(BaseSettings): tg_media_channel_id: str = "" # Optional storage channel tg_admin_ids: str = "" # Comma-separated admin IDs for reports, e.g. "123456,789012" local_bot_api_url: str = "" # e.g., "http://127.0.0.1:8081" - telegram_api_id: str = "" - telegram_api_hash: str = "" + # NOTE: TELEGRAM_API_ID / TELEGRAM_API_HASH are intentionally not modeled here - + # they're only consumed by docker-entrypoint.sh (raw env) to start the local + # telegram-bot-api binary, never read from Python. # MAX Messenger Settings max_bot_token: str = "" diff --git a/src/main.py b/src/main.py index 309d21c..f840341 100644 --- a/src/main.py +++ b/src/main.py @@ -59,7 +59,7 @@ class ServiceApp: if not settings.vk_source: raise ValueError("VK_SOURCE is not set in configuration") - async with VKClient() as vk: + async with VKClient(rps=settings.vk_rate_limit_rps) as vk: screen_name, owner_id, name = await vk.resolve_group(settings.vk_source) self.vk_group_owner_id = owner_id self.vk_group_name = name @@ -81,6 +81,9 @@ class ServiceApp: text=post.text, raw_data=post.raw, ) + existing = await self.db.get_post(post.owner_id, post.post_id) or {} + tg_done = existing.get("tg_status") in ("published", "skipped") + max_done = existing.get("max_status") in ("published", "skipped") media_processor = MediaProcessor(is_local_tg_api=self.tg_poster.is_local_api) processed_media = [] @@ -88,44 +91,53 @@ class ServiceApp: result_summary: dict[str, Any] = { "vk_post_id": post.post_id, "vk_post_url": vk_url, - "tg_status": "pending", - "tg_url": None, + "tg_status": existing.get("tg_status", "pending") if tg_done else "pending", + "tg_url": existing.get("tg_url") if tg_done else None, "tg_error": None, - "max_status": "pending", - "max_url": None, + "max_status": existing.get("max_status", "pending") if max_done else "pending", + "max_url": existing.get("max_url") if max_done else None, "max_error": None, } try: - # 1. Download/extract media - if post.media: + # 1. Download/extract media (skip entirely if both platforms are already done) + if post.media and not (tg_done and max_done): logger.info("Downloading {} media items for post #{}...", len(post.media), post.post_id) processed_media = await media_processor.process_media_items(post.media) - # 2. Publish to Telegram - try: - tg_mids, tg_url = await self.tg_poster.post_to_telegram( - raw_text=post.text, - media_items=processed_media, - vk_url=vk_url, - ) - await self.db.update_tg_result( - post_db_id=post_db_id, - status="published", - message_ids=tg_mids, - url=tg_url, - ) - result_summary["tg_status"] = "published" - result_summary["tg_url"] = tg_url - except Exception as exc: - err = str(exc) - logger.exception("Telegram post error for #{}: {}", post.post_id, exc) - await self.db.update_tg_result(post_db_id=post_db_id, status="failed", error=err) - result_summary["tg_status"] = "failed" - result_summary["tg_error"] = err + # 2. Publish to Telegram (only if not already published/skipped for this post) + if tg_done: + logger.debug("Post #{} already resolved for Telegram ({}), skipping resend.", post.post_id, existing.get("tg_status")) + else: + try: + tg_mids, tg_url = await self.tg_poster.post_to_telegram( + raw_text=post.text, + media_items=processed_media, + vk_url=vk_url, + ) + await self.db.update_tg_result( + post_db_id=post_db_id, + status="published", + message_ids=tg_mids, + url=tg_url, + ) + result_summary["tg_status"] = "published" + result_summary["tg_url"] = tg_url + except Exception as exc: + err = str(exc) + logger.exception("Telegram post error for #{}: {}", post.post_id, exc) + await self.db.update_tg_result(post_db_id=post_db_id, status="failed", error=err) + result_summary["tg_status"] = "failed" + result_summary["tg_error"] = err - # 3. Publish to MAX Messenger - if settings.max_bot_token and settings.max_chat_id: + # 3. Publish to MAX Messenger (only if not already published/skipped for this post) + if not (settings.max_bot_token and settings.max_chat_id): + if not max_done: + await self.db.update_max_result(post_db_id=post_db_id, status="skipped") + result_summary["max_status"] = "skipped" + elif max_done: + logger.debug("Post #{} already resolved for MAX ({}), skipping resend.", post.post_id, existing.get("max_status")) + else: try: max_mids, max_url = await self.max_poster.post_to_max( raw_text=post.text, @@ -146,9 +158,6 @@ class ServiceApp: await self.db.update_max_result(post_db_id=post_db_id, status="failed", error=err) result_summary["max_status"] = "failed" result_summary["max_error"] = err - else: - await self.db.update_max_result(post_db_id=post_db_id, status="skipped") - result_summary["max_status"] = "skipped" finally: # Immediate cleanup of temporary media files @@ -167,7 +176,7 @@ class ServiceApp: published_reports: list[dict[str, Any]] = [] try: - async with VKClient() as vk: + async with VKClient(rps=settings.vk_rate_limit_rps) as vk: latest_posts = await vk.get_latest_posts( owner_id=self.vk_group_owner_id, count=settings.vk_check_count, diff --git a/src/max_poster.py b/src/max_poster.py index 542e8e1..efeab4a 100644 --- a/src/max_poster.py +++ b/src/max_poster.py @@ -11,11 +11,11 @@ import aiohttp try: from .config import settings from .media_processor import ProcessedMedia - from .text_formatter import format_post_text, split_message_chunks + from .text_formatter import build_media_unavailable_note, format_post_text, split_message_chunks except (ImportError, ValueError): from config import settings from media_processor import ProcessedMedia - from text_formatter import format_post_text, split_message_chunks + from text_formatter import build_media_unavailable_note, format_post_text, split_message_chunks MAX_MESSAGE_LIMIT = 4000 MAX_MEDIA_ITEMS = 10 @@ -233,6 +233,21 @@ class MAXPoster: return {"type": media_type, "payload": payload} + async def upload_media_group( + self, client: MAXAPIClient, items: list[ProcessedMedia] + ) -> list[dict[str, Any]]: + attachments: list[dict[str, Any]] = [] + for item in items: + try: + att = await self.upload_media_item(client, item) + if att: + attachments.append(att) + except Exception as exc: + logger.warning("MAX media upload failed for {}: {}", item.attachment_id, exc) + if attachments: + await self.wait_for_videos(client, attachments) + return attachments + async def post_to_max( self, raw_text: str, @@ -250,27 +265,29 @@ class MAXPoster: vk_url=vk_url, ) + link_only = [m for m in media_items if m.is_link_only] + note = build_media_unavailable_note(link_only, parse_mode="html") + if note: + formatted_text = f"{formatted_text}\n\n{note}" if formatted_text else note + chunks = split_message_chunks(formatted_text, self.message_limit) - valid_media = [m for m in media_items if not m.is_link_only and m.local_path][:MAX_MEDIA_ITEMS] + valid_media = [m for m in media_items if not m.is_link_only and m.local_path] + # Chunk into groups of MAX_MEDIA_ITEMS instead of silently dropping the excess: + # the first group rides with the text message, extra groups go out as follow-ups. + media_groups = ( + [valid_media[i : i + MAX_MEDIA_ITEMS] for i in range(0, len(valid_media), MAX_MEDIA_ITEMS)] + if valid_media + else [[]] + ) message_ids: list[str] = [] first_url: Optional[str] = None async with MAXAPIClient(self.token, self.api_base_url) as client: - attachments: list[dict[str, Any]] = [] - for item in valid_media: - try: - att = await self.upload_media_item(client, item) - if att: - attachments.append(att) - except Exception as exc: - logger.warning("MAX media upload failed for {}: {}", item.attachment_id, exc) - - if attachments: - await self.wait_for_videos(client, attachments) + first_attachments = await self.upload_media_group(client, media_groups[0]) first_text = chunks[0] if chunks else "" - res = await self.send_message_waiting_for_media(client, first_text, attachments) + res = await self.send_message_waiting_for_media(client, first_text, first_attachments) first_mid = self.message_id_from_response(res) if first_mid: @@ -289,6 +306,16 @@ class MAXPoster: except Exception as exc: logger.warning("MAX auto reaction failed: {}", exc) + for group in media_groups[1:]: + attachments = await self.upload_media_group(client, group) + if not attachments: + continue + await asyncio.sleep(1.0) + sub_res = await self.send_message_waiting_for_media(client, "", attachments) + sub_mid = self.message_id_from_response(sub_res) + if sub_mid: + message_ids.append(sub_mid) + for chunk in chunks[1:]: await asyncio.sleep(1.0) sub_res = await client.send_message(self.chat_id, chunk) diff --git a/src/media_processor.py b/src/media_processor.py index 6e052f3..e36acfe 100644 --- a/src/media_processor.py +++ b/src/media_processor.py @@ -123,13 +123,14 @@ class MediaProcessor: "--no-playlist", "--match-filter", f"duration <= {settings.video_max_duration_sec}", "--merge-output-format", "mp4", + # Every fallback keeps the height cap: without it yt-dlp can pull an + # arbitrarily large/high-res stream only to have it discarded afterwards + # by the size check below, burning bandwidth and the whole timeout budget. "-f", ( f"best[height<={settings.video_max_height}][filesize<{max_size}]" f"/best[height<={settings.video_max_height}]" - f"/bestvideo[height<={settings.video_max_height}][filesize<{max_size}]+bestaudio/best" - f"/bestvideo[height<={settings.video_max_height}]+bestaudio/best" - f"/best[filesize<{max_size}]" - f"/best" + f"/bestvideo[height<={settings.video_max_height}][filesize<{max_size}]+bestaudio" + f"/bestvideo[height<={settings.video_max_height}]+bestaudio" ), "--quiet", "--no-warnings", ]) diff --git a/src/text_formatter.py b/src/text_formatter.py index 08b5b3e..c07ce63 100644 --- a/src/text_formatter.py +++ b/src/text_formatter.py @@ -2,6 +2,9 @@ from __future__ import annotations import html import re +from html.parser import HTMLParser +from typing import Optional +from urllib.parse import urlparse try: from .config import settings except (ImportError, ValueError): @@ -43,33 +46,134 @@ def strip_trailing_hashtags(text: str) -> str: return "\n".join(lines).rstrip() +# Matches VK bracket markup: [club123|Name], [id123|Name], [public123|Name], +# [event123|Name] or a raw-URL bracket link [https://example.com|Title] +_VK_LINK_RE = re.compile( + r"\[(?:(?Pclub|id|public|event)(?P\d+)|(?Phttps?://[^\s\|\]]+))\|(?P[^\]]+)\]" +) + + def clean_vk_wiki_links(text: str, parse_mode: str = "html") -> str: """ - Converts VK wiki links to clickable links: + Converts VK bracket markup into real links, escaping everything else so the + result is always safe to send with parse_mode="HTML": - [club12345|Name] -> <a href="https://vk.com/club12345">Name</a> - - [id12345|Name] -> <a href="https://vk.com/id12345">Name</a> - - [public12345|Name] -> <a href="https://vk.com/public12345">Name</a> - - [event12345|Name] -> <a href="https://vk.com/event12345">Name</a> + - [id12345|Name] -> <a href="https://vk.com/id12345">Name</a> - [https://example.com|Title] -> <a href="https://example.com">Title</a> + Any text outside of recognized bracket markup is HTML-escaped, so stray + "&", "<", ">" characters in real VK post text never break the parser. """ - def _replace_vk(match: re.Match) -> str: - prefix = match.group(1) - obj_id = match.group(2) - title = match.group(3) - if parse_mode == "html": - return f'<a href="https://vk.com/{prefix}{obj_id}">{html.escape(title)}</a>' - return f"[{title}](https://vk.com/{prefix}{obj_id})" + if parse_mode != "html": + def _replace_plain(match: re.Match) -> str: + title = match.group("title") + if match.group("prefix"): + target = f"https://vk.com/{match.group('prefix')}{match.group('obj_id')}" + else: + target = match.group("url") + return f"[{title}]({target})" + return _VK_LINK_RE.sub(_replace_plain, text) - def _replace_url(match: re.Match) -> str: - url = match.group(1) - title = match.group(2) - if parse_mode == "html": - return f'<a href="{html.escape(url, quote=True)}">{html.escape(title)}</a>' - return f"[{title}]({url})" + out: list[str] = [] + last_end = 0 + for match in _VK_LINK_RE.finditer(text): + out.append(html.escape(text[last_end:match.start()])) + title = html.escape(match.group("title")) + if match.group("prefix"): + href = f"https://vk.com/{match.group('prefix')}{match.group('obj_id')}" + else: + href = html.escape(match.group("url"), quote=True) + out.append(f'<a href="{href}">{title}</a>') + last_end = match.end() + out.append(html.escape(text[last_end:])) + return "".join(out) - text = re.sub(r"\[(club|id|public|event)(\d+)\|([^\]]+)\]", _replace_vk, text) - text = re.sub(r"\[(https?://[^\s\|]+)\|([^\]]+)\]", _replace_url, text) - return text + +_ALLOWED_WRAPPER_TAGS = {"b", "strong", "i", "em", "u", "s", "code", "a"} + + +class _SafeHTMLNormalizer(HTMLParser): + """Normalizes admin-supplied header/footer/tags text: keeps a small safe + subset of inline HTML tags (with href-scheme validation for <a>) and + HTML-escapes everything else, instead of the previous all-or-nothing + '"<" not in text' heuristic.""" + + def __init__(self) -> None: + super().__init__(convert_charrefs=True) + self.out: list[str] = [] + self.open_tags: list[str] = [] + + def handle_starttag(self, tag: str, attrs: list[tuple[str, Optional[str]]]) -> None: + if tag not in _ALLOWED_WRAPPER_TAGS: + return + if tag == "a": + href = next((v for k, v in attrs if k == "href" and v), "") + parsed = urlparse(href) + if parsed.scheme not in ("http", "https") or not parsed.netloc: + return + self.out.append(f'<a href="{html.escape(href, quote=True)}">') + else: + self.out.append(f"<{tag}>") + self.open_tags.append(tag) + + def handle_startendtag(self, tag: str, attrs: list[tuple[str, Optional[str]]]) -> None: + if tag == "br": + self.out.append("\n") + + def handle_endtag(self, tag: str) -> None: + if tag == "br": + self.out.append("\n") + return + if tag in self.open_tags: + while self.open_tags: + t = self.open_tags.pop() + self.out.append(f"</{t}>") + if t == tag: + break + + def handle_data(self, data: str) -> None: + self.out.append(html.escape(data)) + + def normalized(self) -> str: + while self.open_tags: + self.out.append(f"</{self.open_tags.pop()}>") + return "".join(self.out) + + +def normalize_wrapper_text(text: str, parse_mode: str = "html") -> str: + """Safely prepares admin-configured header/footer/tags text for sending.""" + text = str(text or "").strip() + if not text: + return "" + if parse_mode != "html": + return text + parser = _SafeHTMLNormalizer() + parser.feed(text) + parser.close() + return parser.normalized() + + +def build_media_unavailable_note(link_only_items: list, parse_mode: str = "html") -> str: + """Builds a short note listing media that couldn't be attached (too big, + too long, private, download failed, etc.) with a link to the original, + so readers aren't left with no idea that media was omitted.""" + if not link_only_items: + return "" + lines = [] + for item in link_only_items: + url = str(getattr(item, "original_url", "") or "").strip() + media_type = str(getattr(item, "media_type", "медиа") or "медиа") + if not url: + continue + if parse_mode == "html": + lines.append(f'- {html.escape(media_type)}: <a href="{html.escape(url, quote=True)}">ссылка</a>') + else: + lines.append(f"- {media_type}: {url}") + if not lines: + return "" + header = "Не удалось прикрепить медиа, оригинал:" + if parse_mode == "html": + header = f"<i>{html.escape(header)}</i>" + return header + "\n" + "\n".join(lines) def format_post_text( @@ -82,9 +186,9 @@ def format_post_text( bold_first_line: bool = True, vk_url: Optional[str] = None, ) -> str: - header = (settings.header_text if header is None else header).strip() - footer = (settings.footer_text if footer is None else footer).strip() - tags = (settings.common_tags if tags is None else tags).strip() + header = normalize_wrapper_text(settings.header_text if header is None else header, parse_mode) + footer = normalize_wrapper_text(settings.footer_text if footer is None else footer, parse_mode) + tags = normalize_wrapper_text(settings.common_tags if tags is None else tags, parse_mode) body = strip_trailing_hashtags(raw_text) lines = clean_dividers(body.splitlines()) @@ -97,10 +201,9 @@ def format_post_text( formatted_lines: list[str] = [] for idx, line in enumerate(lines): - cleaned_line = clean_vk_wiki_links(line, parse_mode=parse_mode) if parse_mode == "html" else line + cleaned_line = clean_vk_wiki_links(line, parse_mode=parse_mode) if idx == title_idx and bold_first_line and cleaned_line.strip(): if parse_mode == "html": - # If clean_vk_wiki_links was run, keep existing <a> tags safe formatted_lines.append(f"<b>{cleaned_line.strip()}</b>") else: formatted_lines.append(f"**{cleaned_line.strip()}**") @@ -115,12 +218,12 @@ def format_post_text( parts: list[str] = [] if header: - parts.append(html.escape(header) if parse_mode == "html" and "<" not in header else header) + parts.append(header) if normalized_body: parts.append(normalized_body) if footer: - parts.append(html.escape(footer) if parse_mode == "html" and "<" not in footer else footer) + parts.append(footer) if tags: - parts.append(html.escape(tags) if parse_mode == "html" and "<" not in tags else tags) + parts.append(tags) return "\n\n".join(part for part in parts if part).strip() diff --git a/src/tg_poster.py b/src/tg_poster.py index 5acbb16..a4ad6b2 100644 --- a/src/tg_poster.py +++ b/src/tg_poster.py @@ -2,8 +2,6 @@ from __future__ import annotations from loguru import logger import asyncio -import os -import re from pathlib import Path from typing import Any, Optional import aiohttp @@ -15,11 +13,11 @@ from aiogram.types import FSInputFile, InputMediaPhoto, InputMediaVideo try: from .config import settings from .media_processor import ProcessedMedia - from .text_formatter import format_post_text, split_message_chunks + from .text_formatter import build_media_unavailable_note, format_post_text, split_message_chunks except (ImportError, ValueError): from config import settings from media_processor import ProcessedMedia - from text_formatter import format_post_text, split_message_chunks + from text_formatter import build_media_unavailable_note, format_post_text, split_message_chunks MAX_MEDIA_GROUP = 10 MAX_RICH_MEDIA = 50 @@ -175,8 +173,8 @@ class TelegramPoster: def build_rich_text_html(self, text: str) -> str: paragraphs = [] - for p in text.strip().split("\n\n"): - body = "<br/>".join(line for line in p.splitlines() if line.strip()) + for paragraph in str(text or "").strip().split("\n\n"): + body = "<br/>".join(line for line in paragraph.splitlines() if line.strip()) if body: paragraphs.append(f"<p>{body}</p>") return "\n".join(paragraphs) @@ -184,18 +182,18 @@ class TelegramPoster: def build_rich_message( self, text: str, media_items: list[ProcessedMedia], file_ids: dict[str, str] ) -> Optional[dict[str, Any]]: - rich_media = [] - media_tags = [] - - valid_items = [ - m for m in media_items if m.attachment_id in file_ids - ][:MAX_RICH_MEDIA] - - if not valid_items: + """Ported from new_vk_parser's tg_poster.py: bails to None (legacy fallback) + if any media item lacks a file_id, since a rich-message collage can't + partially reference missing media.""" + if len(media_items) > MAX_RICH_MEDIA: return None - for idx, item in enumerate(valid_items): - f_id = file_ids[item.attachment_id] + rich_media = [] + media_tags = [] + for idx, item in enumerate(media_items[:MAX_RICH_MEDIA]): + f_id = file_ids.get(item.attachment_id) + if not f_id: + return None media_id = f"m{idx}" m_type = "photo" if item.media_type == "photo" else "video" rich_media.append({"id": media_id, "media": {"type": m_type, "media": f_id}}) @@ -204,6 +202,9 @@ class TelegramPoster: else: media_tags.append(f'<video src="tg://video?id={media_id}"></video>') + if not rich_media: + return None + rich_text = self.build_rich_text_html(text) if len(rich_text) > MAX_RICH_TEXT: return None @@ -224,9 +225,10 @@ class TelegramPoster: if self.thread_id: data["message_thread_id"] = int(self.thread_id) - base_url = (settings.local_bot_api_url or "").strip().rstrip("/") or "https://api.telegram.org" - url = f"{base_url}/bot{settings.tg_bot_token}/sendRichMessage" - + # Always the cloud endpoint: this call only references already-uploaded + # file_ids, no raw bytes cross the wire, so routing via the local Bot API + # server buys nothing here. + url = f"https://api.telegram.org/bot{settings.tg_bot_token}/sendRichMessage" timeout = aiohttp.ClientTimeout(total=90) async with aiohttp.ClientSession(timeout=timeout) as session: async with session.post(url, json=data) as resp: @@ -237,12 +239,17 @@ class TelegramPoster: mid = res.get("message_id") if mid: return [int(mid)] - raise RichMessageUnavailable("sendRichMessage ok but no message_id") + raise RichMessageUnavailable("sendRichMessage returned no message_id") - desc = str(payload.get("description") or f"HTTP {resp.status}") - raise RichMessageUnavailable(desc) + description = str(payload.get("description") or f"HTTP {resp.status}") + if "Too Many Requests" in description and isinstance(payload.get("parameters"), dict): + retry_after = float(payload["parameters"].get("retry_after") or 0) + if retry_after > 0: + logger.warning("Telegram rich message flood control, sleep {}s", retry_after) + await asyncio.sleep(retry_after + 0.5) + raise RichMessageUnavailable(description) - async def send_legacy_media_post( + async def send_media_post( self, text: str, media_items: list[ProcessedMedia], file_ids: dict[str, str] ) -> list[int]: if not self.bot: @@ -366,10 +373,11 @@ class TelegramPoster: ) -> tuple[list[int], Optional[str]]: """ Main Telegram posting routine: - 1. Formats text for HTML parse mode. - 2. Uploads media (or storage channel if configured) to get file_ids. - 3. Tries sendRichMessage first. - 4. If unavailable, falls back to legacy media groups / single media / text. + 1. Formats text for HTML parse mode and notes any media that couldn't be attached. + 2. Uploads media to the storage channel (if configured) to obtain reusable file_ids. + 3. Tries sendRichMessage (Bot API 10.1+) for a proper collage + rich text. + 4. Falls back to standard aiogram calls (send_photo/send_video/send_media_group) + if rich message is unavailable (no storage channel, old Bot API server, etc). """ formatted_text = format_post_text( raw_text, @@ -379,26 +387,29 @@ class TelegramPoster: ) valid_media = [m for m in media_items if not m.is_link_only and m.local_path] + link_only = [m for m in media_items if m.is_link_only] + note = build_media_unavailable_note(link_only, parse_mode="html") + if note: + formatted_text = f"{formatted_text}\n\n{note}" if formatted_text else note - # 1. Obtain file_ids if we have storage channel or if we want rich message + # Obtain file_ids from the storage channel, if configured, to avoid re-uploading + # (also a prerequisite for rich messages, which reference media by file_id). file_ids: dict[str, str] = {} if valid_media and self.storage_chat_id: file_ids = await self.upload_media_for_file_ids(valid_media) - # 2. Try Rich Message if file_ids are available - if file_ids: + if valid_media and file_ids: rich_msg = self.build_rich_message(formatted_text, valid_media, file_ids) if rich_msg: try: mids = await self.send_rich_message(rich_msg) url = tg_message_url(self.chat_id, mids[0]) if mids else None - logger.info("Sent Telegram Rich Message: {}", mids) + logger.info("Sent Telegram rich message: {}", mids) return mids, url except RichMessageUnavailable as exc: - logger.warning("Telegram sendRichMessage failed: {}. Falling back to legacy.", exc) + logger.warning("Telegram sendRichMessage failed: {}. Falling back to standard send.", exc) - # 3. Fallback to legacy media group / text - mids = await self.send_legacy_media_post(formatted_text, valid_media, file_ids) + mids = await self.send_media_post(formatted_text, valid_media, file_ids) url = tg_message_url(self.chat_id, mids[0]) if mids else None - logger.info("Sent Telegram Legacy Message: {}", mids) + logger.info("Sent Telegram message: {}", mids) return mids, url diff --git a/src/vk_client.py b/src/vk_client.py index 9221df9..882fac4 100644 --- a/src/vk_client.py +++ b/src/vk_client.py @@ -6,6 +6,7 @@ import re import time from dataclasses import dataclass from typing import Any, Optional +from urllib.parse import urlparse import aiohttp try: from .config import settings @@ -54,7 +55,6 @@ class VKPost: text: str media: list[VKMediaItem] raw: dict[str, Any] - is_pinned: bool = False is_repost: bool = False @@ -200,6 +200,24 @@ class VKClient: title=video.get("title"), ) ) + elif att_type == "link": + link = att.get("link") or {} + link_url = str(link.get("url") or "") + host = urlparse(link_url).hostname or "" + host = host.lower().removeprefix("www.").removeprefix("m.") + if host in ("youtube.com", "youtube-nocookie.com", "youtu.be"): + items.append( + VKMediaItem( + media_type="video", + url=link_url, + attachment_id=f"link_{abs(hash(link_url))}", + title=str(link.get("title") or ""), + ) + ) + else: + logger.debug("Skipping unsupported VK link attachment: {}", link_url) + elif att_type not in ("photo", "video"): + logger.debug("Skipping unsupported VK attachment type: {}", att_type) return items async def get_latest_posts(self, owner_id: int, count: int = 10) -> list[VKPost]: @@ -209,7 +227,6 @@ class VKClient: for raw in items: if raw.get("is_deleted") or not raw.get("id") or not raw.get("date"): continue - is_pinned = bool(raw.get("is_pinned")) is_repost = bool(raw.get("copy_history")) media = self.extract_media(raw) posts.append( @@ -220,7 +237,6 @@ class VKClient: text=str(raw.get("text") or ""), media=media, raw=raw, - is_pinned=is_pinned, is_repost=is_repost, ) )