Compare commits

..

7 Commits

Author SHA1 Message Date
bvn13
76d8272aac update-7 2026-03-23 12:45:37 +03:00
bvn13
324cd8a959 update-5, update-6 2026-03-23 12:12:55 +03:00
bvn13
9bd0ab2e72 update-4 2026-03-23 11:45:43 +03:00
bvn13
05c4016747 update-3 2026-03-23 10:50:20 +03:00
bvn13
ef870b985b update-2 logs 2026-03-23 10:04:47 +03:00
bvn13
c977da8382 ignore data 2026-03-23 01:08:14 +03:00
bvn13
edcf20a322 update-2 2026-03-23 00:59:47 +03:00
12 changed files with 401 additions and 7 deletions

2
.gitignore vendored
View File

@ -1 +1,3 @@
.env .env
data/

83
docs/update-1.md Normal file
View File

@ -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` добавлена проверка наличия элемента `<delay>`:
```python
if msg["delay"]["stamp"]:
return # историческое сообщение, пропускаем
```
---
## 7. Логирование
Добавлено логирование на ключевых этапах обработки команд в `command_handler.py`:
- `INFO` — распознанная команда (`room_jid`, `caller_jid`, команда, аргумент)
- `INFO` — отклонение по правам (не-админ пытается выполнить команду)
- `DEBUG` — входящее сообщение, реальный JID, результат проверки прав
- `WARNING` — некорректный ввод (команда без обязательного аргумента, неверный интервал)

63
docs/update-2.md Normal file
View File

@ -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-содержимое
# Обратите внимание: тег <img> должен быть внутри <body> в пространстве имен XHTML
xhtml_body = '<body xmlns="http://www.w3.org/1999/xhtml">' + \
' <p>Смотри: <img src="https://example.com/image.jpg" /></p>' + \
'</body>'
# Включаем XHTML-IM плагин
msg['html']['body'] = xhtml_body
msg.send()
```
3. картинка должна размещаться ДО текста новости
4. Fallback (msg['body']) при XHTML-сообщении — для клиентов без XEP-0071 в тексте писать просто текст без ссылок на картинку
5. при наличии нескольких enclosure брать первый с подходящим type
### Если новость имеет и enclosure-картинку, и description
— порядок в XHTML-теле: <img> → title → description → link

20
docs/update-3.md Normal file
View File

@ -0,0 +1,20 @@
# Обновление 3
## Задача
1. в обновлении docs/update-2.md были добавлены description и картинка новости в ее текст
2. но оформление новости выглядит хорошим (user exp) только в случае, когда есть заголовок, описание и ссылка
3. необходимо для всех вариантов наполнения новостей из RSS разделить двумя переводами строки блоки:
- картинка (если есть, то отделяем разрывом в два перевода строки)
- заголовок (есть всегда, после него разрыв)
- дескрипшен (если есть, то отделяем после него разывом)
- ссылка (есть всегда, после нее не нужен разрыв, т.к. это завершающий блок)
## Техническая реализация
1. в docs/update-2.md было реализовано 2 метода отправки новостей: _build_plain и _build_xhtml - выбор определяется наличием ссылки на картинку
2. необходимо отказаться от _build_plain в пользу _build_xhtml при любых вариантах - это даст новостям одинаковый вид
3. но блоки <p> необходимо отделять друг от друга двумя блоками <br/>
4. fallback <body> должен содержать двойные '\n' после каждого блока (кроме завершающего - ссылки), описанного выше

68
docs/update-4.md Normal file
View File

@ -0,0 +1,68 @@
# Обновление 4 — Исправление отображения картинок
## Контекст
В update-2 и update-3 была реализована отправка новостей через XHTML-IM (XEP-0071) с картинкой через тег `<img>`.
По результатам тестирования картинки не отображаются:
- **Gajim** — показывает URL картинки вместо изображения (использует plain-text fallback)
- **Monocle Chat (форк Conversations)** — показывает незагруженную заглушку
Причины установлены в ходе анализа лога stanza.
---
## Исправление 1 — двойной `<body>` в XHTML-IM
### Проблема
slixmpp xep_0071 при присвоении `msg["html"]["body"] = xhtml_body` самостоятельно оборачивает
содержимое в `<body xmlns="http://www.w3.org/1999/xhtml">`. Текущая реализация `_build_xhtml`
также включает этот тег, в результате в stanza попадает вложенный `<body>`:
```xml
<body xmlns="http://www.w3.org/1999/xhtml"> ← добавляет slixmpp
<body> ← добавляет наш код
<p>...</p>
</body>
</body>
```
Это невалидный XHTML-IM, что приводит к использованию plain-text fallback в строгих клиентах.
### Решение
В методе `_build_xhtml` убрать обёртку `<body xmlns="...">...</body>` — возвращать только
внутренние элементы (`<p>`, `<br/>`). slixmpp добавит `<body>` сам.
---
## Исправление 2 — OOB для отображения картинок в Conversations-клиентах
### Проблема
Клиенты семейства Conversations (включая Monocle Chat) блокируют загрузку внешних изображений
из XHTML-IM `<img src="...">` по умолчанию — это намеренная защита от трекинг-пикселей.
Картинка парсится, но не загружается.
### Решение
Дополнительно к XHTML-IM прикреплять URL картинки как **Out-of-Band Data (XEP-0066)**.
Conversations и его форки умеют рендерить OOB-ссылки как inline-превью.
Для этого к сообщению добавляется элемент:
```xml
<x xmlns="jabber:x:oob">
<url>https://example.com/image.jpg</url>
</x>
```
### Техническая реализация
1. Зарегистрировать плагин `xep_0066` в `JabberConnection`
2. В методе `send_to_room_xhtml` добавить OOB-поле к сообщению, если передан `image_url`:
```python
msg["oob"]["url"] = image_url
```
3. Сигнатуру `send_to_room_xhtml` расширить необязательным параметром `image_url: str | None = None`
4. В `JabberNewsPublisher.publish` передавать `item.image_url` в `send_to_room_xhtml`

26
docs/update-5.md Normal file
View File

@ -0,0 +1,26 @@
# Обновление 5 — Нормализация URL картинок
## Проблема
Клиент Conversations (и его форки) не отображает OOB-картинки, если URL содержит символ `:`
в path-части (например, `…/1579780545_0:182:3048:1897_…jpg`).
При парсинге OOB `<url>` клиент интерпретирует `:` как разделитель схемы или порта
и обрезает URL — ссылка становится невалидной, картинка не загружается.
URL без двоеточий в пути отображаются корректно.
## Решение
URL-энкодировать path-часть URL картинки перед сохранением в `NewsItem`.
Символ `:` кодируется как `%3A`. Согласно RFC 3986, percent-encoded символы
эквивалентны оригинальным — CDN-серверы обязаны принимать такие URL.
## Техническая реализация
1. В `adapters/sources/rss/fetcher.py` добавить функцию `_normalize_image_url(url: str) -> str`
2. Функция использует `urllib.parse` (стандартная библиотека, без новых зависимостей):
- разобрать URL через `urlparse`
- перекодировать path через `quote(path, safe='/')` — слэши остаются, `:` кодируется
- собрать обратно через `urlunparse`
3. Вызывать `_normalize_image_url` в `_extract_image_url` перед возвратом URL

21
docs/update-6.md Normal file
View File

@ -0,0 +1,21 @@
# Обновление 6 — Убрать картинку из тела сообщения
## Контекст
В update-2 картинка добавлялась двумя способами:
- в XHTML-теле через `<p><img src="..." /></p>`
- в plain-text fallback через URL картинки первым блоком
В update-4 добавлен OOB (XEP-0066), который обеспечивает отображение картинки
в клиентах семейства Conversations. Таким образом, `<img>` и URL в fallback
стали избыточными и создают визуальный мусор в клиентах, не рендерящих XHTML.
## Задача
Убрать блок картинки из тела сообщения:
1. В `_build_xhtml` — убрать `<p><img src="..." /></p><br/><br/>`
2. В `_build_plain` — убрать добавление `image_url` в список блоков
Поле `image_url` в `NewsItem` и логика его извлечения в fetcher'е остаются —
оно по-прежнему используется для OOB.

31
docs/update-7.md Normal file
View File

@ -0,0 +1,31 @@
# Обновление 7 — Пропускать OOB если URL картинки содержит недопустимые символы
## Контекст
В update-5 была добавлена нормализация URL картинок: символ `:` в path кодировался как `%3A`.
Выяснилось, что CDN принимает закодированный URL (200 OK), но клиент Conversations
всё равно не отображает картинку — причина в hotlink protection на стороне CDN.
Таким образом, нормализация URL не решает проблему и бесполезна.
Практическое наблюдение: URL с `:` в path — признак CDN с hotlink protection.
Такие картинки не отобразятся в клиентах в любом случае.
Решение: не добавлять OOB для URL, содержащих `:` в path-части.
## Задача
1. В `adapters/sources/rss/fetcher.py`:
- Удалить функцию `_normalize_image_url` и импорты `urllib.parse`
- В `_extract_image_url` проверять raw URL: если path-часть содержит `:` — возвращать `None`
## Техническая реализация
Проверка выполняется через `urlparse`:
```python
from urllib.parse import urlparse
p = urlparse(url)
if ':' in p.path:
return None
return url
```

View File

@ -25,6 +25,8 @@ class JabberConnection(ClientXMPP, JabberRoomJoiner, JabberRoomLeaver, MucJidRes
self._rooms_on_start: List[str] = [] self._rooms_on_start: List[str] = []
self.register_plugin("xep_0045") # MUC self.register_plugin("xep_0045") # MUC
self.register_plugin("xep_0066") # Out-of-Band Data
self.register_plugin("xep_0071") # XHTML-IM
self.register_plugin("xep_0199") # XMPP Ping self.register_plugin("xep_0199") # XMPP Ping
self.add_event_handler("session_start", self._on_session_start) self.add_event_handler("session_start", self._on_session_start)
@ -95,7 +97,7 @@ class JabberConnection(ClientXMPP, JabberRoomJoiner, JabberRoomLeaver, MucJidRes
async def send_to_room(self, room_jid: str, text: str) -> bool: async def send_to_room(self, room_jid: str, text: str) -> bool:
""" """
Отправляет сообщение в конференцию. Отправляет plain-text сообщение в конференцию.
Возвращает False если возникла ошибка (бот не в комнате). Возвращает False если возникла ошибка (бот не в комнате).
""" """
try: try:
@ -105,6 +107,31 @@ class JabberConnection(ClientXMPP, JabberRoomJoiner, JabberRoomLeaver, MucJidRes
logger.warning("Не удалось отправить сообщение в %s", room_jid) logger.warning("Не удалось отправить сообщение в %s", room_jid)
return False return False
async def send_to_room_xhtml(
self,
room_jid: str,
plain_text: str,
xhtml_body: str,
image_url: Optional[str] = None,
) -> bool:
"""
Отправляет XHTML-IM сообщение в конференцию (XEP-0071).
plain_text fallback для клиентов без поддержки XHTML.
image_url если указан, добавляется OOB (XEP-0066) для клиентов семейства Conversations.
Возвращает False если возникла ошибка.
"""
try:
msg = self.make_message(mto=room_jid, mtype="groupchat")
msg["body"] = plain_text
msg["html"]["body"] = xhtml_body
if image_url:
msg["oob"]["url"] = image_url
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: def get_real_jid(self, room_jid: str, nick: str) -> str | None:
"""Возвращает реальный JID участника MUC по нику, или None если недоступен.""" """Возвращает реальный JID участника MUC по нику, или None если недоступен."""
try: try:

View File

@ -1,7 +1,11 @@
import logging
from src.domain.news.entities import NewsItem from src.domain.news.entities import NewsItem
from src.domain.news.ports import NewsPublisher from src.domain.news.ports import NewsPublisher
from .connection import JabberConnection from .connection import JabberConnection
logger = logging.getLogger(__name__)
class JabberNewsPublisher(NewsPublisher): class JabberNewsPublisher(NewsPublisher):
@ -10,8 +14,24 @@ class JabberNewsPublisher(NewsPublisher):
async def publish(self, room_jid: str, item: NewsItem) -> bool: async def publish(self, room_jid: str, item: NewsItem) -> bool:
""" """
Отправляет новость в комнату. Отправляет новость в комнату через XHTML-IM (XEP-0071) с plain-text fallback.
Возвращает False если бот не в комнате. Возвращает False если бот не в комнате.
""" """
text = f"{item.title}\n{item.link}" plain = self._build_plain(item)
return await self._connection.send_to_room(room_jid, text) xhtml = self._build_xhtml(item)
return await self._connection.send_to_room_xhtml(room_jid, plain, xhtml, item.image_url)
def _build_plain(self, item: NewsItem) -> str:
blocks = [item.title]
if item.summary:
blocks.append(item.summary)
blocks.append(item.link)
return "\n\n".join(blocks)
def _build_xhtml(self, item: NewsItem) -> str:
SEP = "<br/><br/>"
parts = [f'<p>{item.title}</p>{SEP}']
if item.summary:
parts.append(f'<p>{item.summary}</p>{SEP}')
parts.append(f'<p><a href="{item.link}">{item.link}</a></p>')
return ''.join(parts)

View File

@ -1,6 +1,9 @@
import asyncio import asyncio
import logging import logging
from typing import List import re
from html import unescape
from typing import List, Optional
from urllib.parse import urlparse
import feedparser import feedparser
@ -11,6 +14,27 @@ from src.domain.subscriptions.entities import Subscription
logger = logging.getLogger(__name__) 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/*.
Возвращает None если URL содержит ':' в path признак hotlink-защищённого CDN.
"""
for enc in entry.get("enclosures", []):
if enc.get("type", "").startswith("image/"):
raw = enc.get("href") or enc.get("url")
if not raw:
return None
if ":" in urlparse(raw).path:
return None
return raw
return None
class RssFetcher(NewsFetcher): class RssFetcher(NewsFetcher):
async def fetch(self, subscription: Subscription) -> List[NewsItem]: async def fetch(self, subscription: Subscription) -> List[NewsItem]:
@ -30,8 +54,16 @@ class RssFetcher(NewsFetcher):
news_id = entry.get("id") or entry.get("link") or "" news_id = entry.get("id") or entry.get("link") or ""
title = entry.get("title", "(без заголовка)") title = entry.get("title", "(без заголовка)")
link = entry.get("link", "") 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: 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 return items

View File

@ -8,3 +8,4 @@ class NewsItem:
title: str title: str
link: str link: str
summary: Optional[str] = None summary: Optional[str] = None
image_url: Optional[str] = None