From edcf20a322f4c663781ce2b58ed8a13e63cceb66 Mon Sep 17 00:00:00 2001 From: bvn13 Date: Mon, 23 Mar 2026 00:59:47 +0300 Subject: [PATCH] update-2 --- docs/update-1.md | 83 +++++++++++++++++++++++++++ docs/update-2.md | 63 ++++++++++++++++++++ src/adapters/jabber/connection.py | 19 +++++- src/adapters/jabber/news_publisher.py | 23 +++++++- src/adapters/sources/rss/fetcher.py | 30 +++++++++- src/domain/news/entities.py | 1 + 6 files changed, 213 insertions(+), 6 deletions(-) create mode 100644 docs/update-1.md create mode 100644 docs/update-2.md diff --git a/docs/update-1.md b/docs/update-1.md new file mode 100644 index 0000000..67cc28e --- /dev/null +++ b/docs/update-1.md @@ -0,0 +1,83 @@ +# Update 1 — Исправления и дополнения к design.md + +Документ фиксирует решения, принятые в ходе реализации и отладки, которые не описаны в `design.md`. + +--- + +## 1. Запуск event loop (main.py) + +**Проблема:** в примере из `design.md` используется `bot.process(forever=True)` — это API sleekxmpp, которого нет в slixmpp. + +**Решение:** slixmpp — asyncio-native библиотека. Точка входа: +- `setup()` — async-функция, собирает все зависимости +- `main()` — синхронная, создаёт event loop вручную через `asyncio.new_event_loop()` +- `connection.connect()` ставит соединение в очередь +- `loop.run_forever()` держит бота живым + +--- + +## 2. Обработка XMPP-сообщений (connection.py) + +**Проблема:** в slixmpp событие `message` срабатывает для всех типов stanza, включая `groupchat`. Регистрация обоих событий (`message` + `groupchat_message`) на один обработчик вызывала двойную обработку каждого сообщения из комнаты. + +**Решение:** один обработчик `_on_message` зарегистрирован только на событие `message`. Внутри — явная диспетчеризация по `msg["type"]`: +- `chat` → фильтр по собственному bare JID +- `groupchat` → фильтр по нику бота (отражённые сервером собственные сообщения) +- прочие типы → игнорируются + +--- + +## 3. leave_muc не является корутиной + +**Проблема:** `muc.leave_muc()` в slixmpp — синхронный метод. `await muc.leave_muc(...)` падал с `TypeError: object NoneType can't be used in 'await' expression`. + +**Решение:** убран `await` у вызова `leave_muc`. + +--- + +## 4. Получение реального JID участника MUC (command_handler.py) + +**Проблема:** `msg.get_plugin("xep_0045")` вызывался на объекте стансы, а не на `ClientXMPP`. Метод всегда возвращал `None`, из-за чего `caller_jid` был `None` для всех участников → все команды, требующие прав админа, молча отбрасывались. + +**Решение:** +- Добавлен порт `MucJidResolver` в `domain/admin/ports.py`: + ```python + class MucJidResolver(ABC): + def get_real_jid(self, room_jid: str, nick: str) -> str | None: ... + ``` +- `JabberConnection` реализует `MucJidResolver` через `self.plugin["xep_0045"].get_jid_property(...)` +- `CommandHandler` принимает `jid_resolver: MucJidResolver` и использует его + +--- + +## 5. Сравнение JID при проверке прав (command_handler.py) + +**Проблема:** xep_0045 возвращает полный JID с ресурсом (`user@server/resource`), а в таблице `rooms` хранится bare JID (`user@server`). Сравнение `room.admin_jid == caller_jid` всегда давало `False`. + +**Решение:** перед сравнением срезается ресурс: +```python +caller_bare = caller_jid.split("/")[0] +return room.admin_jid == caller_bare +``` + +--- + +## 6. Воспроизведение истории при входе в комнату (command_handler.py) + +**Проблема:** при входе бота в MUC-комнату XMPP-сервер воспроизводит историю сообщений (XEP-0091/XEP-0203). Все старые команды (`! help`, `! subscribe`, ...) обрабатывались заново пакетом. + +**Решение:** в начале `_handle_room` добавлена проверка наличия элемента ``: +```python +if msg["delay"]["stamp"]: + return # историческое сообщение, пропускаем +``` + +--- + +## 7. Логирование + +Добавлено логирование на ключевых этапах обработки команд в `command_handler.py`: +- `INFO` — распознанная команда (`room_jid`, `caller_jid`, команда, аргумент) +- `INFO` — отклонение по правам (не-админ пытается выполнить команду) +- `DEBUG` — входящее сообщение, реальный JID, результат проверки прав +- `WARNING` — некорректный ввод (команда без обязательного аргумента, неверный интервал) diff --git a/docs/update-2.md b/docs/update-2.md new file mode 100644 index 0000000..17747ce --- /dev/null +++ b/docs/update-2.md @@ -0,0 +1,63 @@ +# Доработка 2 + +## Задание + +1. необходимо доработать rss адаптер, получающий новости, и модуль отправки в жаббер + +### Улучшение 1 + +1. если RSS item содержит поле description, то текст новости необходимо снабдить этой информацией +2. в этом случае формат новости должен быть расширен: + +``` +title + + +description + + +link +``` + +т.е. между title и description - 2 перевода строки, и между description и link - тоже 2 перевода строки + +3. если description содержит HTML тэги, то выводить нужно голый текст, предварительно очистив от тэгов + +### Улучшение 2 + +1. если RSS item содержит элемент enclosure с параметром type="image/*" (любой image, но у меня пример только для image/jpeg есть - возможно, бывают другие варианты, нужно отработать по маске) +2. то нужно воспользоваться XEP-0071 и отправить картинку вместе с текстом новости - шаблонный пример использования + +``` +import slixmpp +from slixmpp.stanza import Message +from slixmpp.plugins.xep_0071 import XHTML_IM + +class XHTMLBot(slixmpp.ClientXMPP): + async def send_xhtml(self, jid_to): + msg = self.make_message(mto=jid_to) + + # Устанавливаем обычный текст (обязательно для fallback) + msg['body'] = 'Посмотри картинку: https://example.com/image.jpg' + + # Формируем XHTML-содержимое + # Обратите внимание: тег должен быть внутри в пространстве имен XHTML + xhtml_body = '' + \ + '

Смотри:

' + \ + '' + + # Включаем XHTML-IM плагин + msg['html']['body'] = xhtml_body + + msg.send() +``` + +3. картинка должна размещаться ДО текста новости +4. Fallback (msg['body']) при XHTML-сообщении — для клиентов без XEP-0071 в тексте писать просто текст без ссылок на картинку +5. при наличии нескольких enclosure брать первый с подходящим type + + +### Если новость имеет и enclosure-картинку, и description + +— порядок в XHTML-теле: → title → description → link + diff --git a/src/adapters/jabber/connection.py b/src/adapters/jabber/connection.py index 638d053..7063ae2 100644 --- a/src/adapters/jabber/connection.py +++ b/src/adapters/jabber/connection.py @@ -25,6 +25,7 @@ class JabberConnection(ClientXMPP, JabberRoomJoiner, JabberRoomLeaver, MucJidRes self._rooms_on_start: List[str] = [] self.register_plugin("xep_0045") # MUC + self.register_plugin("xep_0071") # XHTML-IM self.register_plugin("xep_0199") # XMPP Ping self.add_event_handler("session_start", self._on_session_start) @@ -95,7 +96,7 @@ class JabberConnection(ClientXMPP, JabberRoomJoiner, JabberRoomLeaver, MucJidRes async def send_to_room(self, room_jid: str, text: str) -> bool: """ - Отправляет сообщение в конференцию. + Отправляет plain-text сообщение в конференцию. Возвращает False если возникла ошибка (бот не в комнате). """ try: @@ -105,6 +106,22 @@ class JabberConnection(ClientXMPP, JabberRoomJoiner, JabberRoomLeaver, MucJidRes logger.warning("Не удалось отправить сообщение в %s", room_jid) return False + async def send_to_room_xhtml(self, room_jid: str, plain_text: str, xhtml_body: str) -> bool: + """ + Отправляет XHTML-IM сообщение в конференцию (XEP-0071). + plain_text — fallback для клиентов без поддержки XHTML. + Возвращает False если возникла ошибка. + """ + try: + msg = self.make_message(mto=room_jid, mtype="groupchat") + msg["body"] = plain_text + msg["html"]["body"] = xhtml_body + msg.send() + return True + except Exception: + logger.warning("Не удалось отправить XHTML-сообщение в %s, пробуем plain-text", room_jid) + return await self.send_to_room(room_jid, plain_text) + def get_real_jid(self, room_jid: str, nick: str) -> str | None: """Возвращает реальный JID участника MUC по нику, или None если недоступен.""" try: diff --git a/src/adapters/jabber/news_publisher.py b/src/adapters/jabber/news_publisher.py index d8fb5cf..fbd22ea 100644 --- a/src/adapters/jabber/news_publisher.py +++ b/src/adapters/jabber/news_publisher.py @@ -11,7 +11,26 @@ class JabberNewsPublisher(NewsPublisher): async def publish(self, room_jid: str, item: NewsItem) -> bool: """ Отправляет новость в комнату. + Если есть картинка — использует XHTML-IM (XEP-0071). Возвращает False если бот не в комнате. """ - text = f"{item.title}\n{item.link}" - return await self._connection.send_to_room(room_jid, text) + plain = self._build_plain(item) + if item.image_url: + xhtml = self._build_xhtml(item) + return await self._connection.send_to_room_xhtml(room_jid, plain, xhtml) + return await self._connection.send_to_room(room_jid, plain) + + def _build_plain(self, item: NewsItem) -> str: + if item.summary: + return f"{item.title}\n\n{item.summary}\n\n{item.link}" + return f"{item.title}\n{item.link}" + + def _build_xhtml(self, item: NewsItem) -> str: + parts = [''] + parts.append(f'

') + parts.append(f'

{item.title}

') + if item.summary: + parts.append(f'

{item.summary}

') + parts.append(f'

{item.link}

') + parts.append('') + return ''.join(parts) diff --git a/src/adapters/sources/rss/fetcher.py b/src/adapters/sources/rss/fetcher.py index 047d442..d5e5c23 100644 --- a/src/adapters/sources/rss/fetcher.py +++ b/src/adapters/sources/rss/fetcher.py @@ -1,6 +1,8 @@ import asyncio import logging -from typing import List +import re +from html import unescape +from typing import List, Optional import feedparser @@ -11,6 +13,20 @@ from src.domain.subscriptions.entities import Subscription logger = logging.getLogger(__name__) +def _strip_html(text: str) -> str: + """Удаляет HTML-теги и декодирует HTML-сущности.""" + text = re.sub(r'<[^>]+>', '', text) + return unescape(text).strip() + + +def _extract_image_url(entry) -> Optional[str]: + """Возвращает URL первого enclosure с type image/*.""" + for enc in entry.get("enclosures", []): + if enc.get("type", "").startswith("image/"): + return enc.get("href") or enc.get("url") + return None + + class RssFetcher(NewsFetcher): async def fetch(self, subscription: Subscription) -> List[NewsItem]: @@ -30,8 +46,16 @@ class RssFetcher(NewsFetcher): news_id = entry.get("id") or entry.get("link") or "" title = entry.get("title", "(без заголовка)") link = entry.get("link", "") - summary = entry.get("summary") + raw_summary = entry.get("summary") + summary = _strip_html(raw_summary) if raw_summary else None + image_url = _extract_image_url(entry) if news_id: - items.append(NewsItem(id=news_id, title=title, link=link, summary=summary)) + items.append(NewsItem( + id=news_id, + title=title, + link=link, + summary=summary, + image_url=image_url, + )) return items diff --git a/src/domain/news/entities.py b/src/domain/news/entities.py index 279fc0c..ffe3d0d 100644 --- a/src/domain/news/entities.py +++ b/src/domain/news/entities.py @@ -8,3 +8,4 @@ class NewsItem: title: str link: str summary: Optional[str] = None + image_url: Optional[str] = None