update-2
This commit is contained in:
parent
d7bb077e49
commit
edcf20a322
83
docs/update-1.md
Normal file
83
docs/update-1.md
Normal 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
63
docs/update-2.md
Normal 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
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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 = ['<body xmlns="http://www.w3.org/1999/xhtml">']
|
||||
parts.append(f'<p><img src="{item.image_url}" alt="" /></p>')
|
||||
parts.append(f'<p>{item.title}</p>')
|
||||
if item.summary:
|
||||
parts.append(f'<p>{item.summary}</p>')
|
||||
parts.append(f'<p><a href="{item.link}">{item.link}</a></p>')
|
||||
parts.append('</body>')
|
||||
return ''.join(parts)
|
||||
|
||||
@ -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
|
||||
|
||||
@ -8,3 +8,4 @@ class NewsItem:
|
||||
title: str
|
||||
link: str
|
||||
summary: Optional[str] = None
|
||||
image_url: Optional[str] = None
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user