commit 6c92f75ca70260bd4704523c3b06f55370e064d1 Author: bvn13 Date: Sun Mar 22 02:20:02 2026 +0300 design diff --git a/docs/design.md b/docs/design.md new file mode 100644 index 0000000..6db6425 --- /dev/null +++ b/docs/design.md @@ -0,0 +1,166 @@ +# Задание + +Необходимо сделать программу для формирования новостей в jabber конференции. +Принцип работы программы: подписывается на RSS ленту и все появляющиеся новости транслирует в jabber конференцию. + +## Интерфейс взаимодействия + +1. Это изначально jabber бот. Управление - администратором только. +2. Управление может происходить личными сообщениями и через сообщения в конференции. +3. Из личных сообщений доступны 5 команд (формат ниже такой: - ): + а) join - зайти в указанную конферению + б) exit - выйти из указанной конференции (в БД запись становится is_enabled=false, а повторный join в комнату включает запись обратно) + в) list - выдает список комнат, в которые бот заведен текущим пользователем (от которого принял команду) - только комнаты, куда именно этот JID отправил команду join + г) list-all - выдает общий список комнат и кто бота в них завел, с признаком is_enabled (команда доступна и показывается в help только суперадмину - владельцу бота) + д) help - показать справку, состояющую из всех команд выше (разный для обычного пользователя и владельца бота) + е) если получено что-то другое, то выдавать в ответ справку, как при команде help +4. Из конференции (комнаты) доступны команды ниже. Команда - это сообщение, начинающееся с определенного набора символов. Формат описания команд: - : + а) subscribe (default 15 min) - подписаться в этой на указанную + б) list - вывести список , на которые оформлена подписка в этот + в) unsubscribe - отписаться в этой от указанной + г) cmd - сменить командные символы на новые + д) help - вывести справку, состояющую из всех команд выше (для комнаты, не для личных сообщений) - причем вместо нужно подставить текущее значение символов. +5. Из конференции можно обратиться к боту - тогда бот должен выдать необходимую информацию для общения с ним. + а) если сообщение начинается с ника бота, то в ответ нужно написать: help - справка по командам + - в нужно подставить текущее значение символов +6. COMMAND_SYMBOLS по-умолчанию задать - '!' (просто восклицательный знак) + +### Уточнения реализации интерфейса + +1. Если комната была выключена долго, при повторном join last_seen не сбрасывается. По запросу из п.7 технической реализации все накопившиеся за это время новости уйдут разом. Это желаемое поведение. +2. П.5 (ответ на ник бота) — для всех или только для админа? П.1 говорит "управление — администратором только", но отвечать на 'botname, help' логично для любого участника комнаты. Явного исключения нет. + - 'botname, help' приравнять к команде '! help' + - вызов команды '! help' из комнаты доступен всем. если вызвал не админ, то в возвращаемом тексте приписать: админ - JID админа +3. Что делать, если команду в комнате пишет не-админ? Описано только "принимать только от админа", но не описана реакция: молча игнорировать или отвечать что-то вроде "недостаточно прав"? + - если это не команда help - игнорировать. +4. Один админ на комнату — что при повторном join другим JID? Уточнено "один админ", но не описано поведение при коллизии: если JID_A уже является админом room1, а JID_B отправляет join room1 — ошибка? Перезапись? Молча игнорируется? + - написать, что "я уже присутствую в комнате, админ - JID админа" +5. Нельзя выполнять exit из "чужой" комнаты (если я не админ - я не могу вывести бота из комнаты) +6. Сейчас команды subscribe/unsubscribe используются в контексте RSS - это сделано намеренно, при появлении других источников будем адаптировать команды управления + +## Техническая реализация + +1. Контейнеризация - docker compose +2. Стек - python, библиотека slixmpp (https://pypi.org/project/slixmpp/). +3. Пример использования библиотеки + +```python +import asyncio +import feedparser +from slixmpp import ClientXMPP + +RECIPIENTS = ["user@example.com"] +RSS_URL = "https://lenta.ru/rss/news" + +class NewsBot(ClientXMPP): + def __init__(self, jid, password): + super().__init__(jid, password) + self.add_event_handler("session_start", self.on_start) + + async def on_start(self, event): + self.send_presence() + await self.get_roster() + while True: + feed = feedparser.parse(RSS_URL) + for entry in feed.entries[:3]: + for recipient in RECIPIENTS: + self.send_message( + mto=recipient, + mbody=f"{entry.title}\n{entry.link}", + mtype="chat" + ) + await asyncio.sleep(300) # каждые 5 минут + +bot = NewsBot("bot@your-server.com", "password") +bot.connect() +bot.process(forever=True) +``` + +4. БД для хранения настроек - SQLite, должна подключаться внутрь docker контейнера, а храниться на хосте + - таблица для хранения COMMAND_SYMBOLS: комната и COMMAND_SYMBOL - но сами символы нужно кешировать, чтобы не искать в БД каждый раз + - таблица для подписок subscriptions: комната, subscription_type (значения: rss), source (URL, etc.), интервал опроса в минутах (но не меньше 5, валиден только для URL-based источников - не телеграм), время последней проверки (timestamp, тоже валидно только для URL-based) + - таблица комнат rooms (админом считается тот, кто добавил бота в комнату): комната, jid админа (команды из комнаты можно принимать только от админа), признак включенности комнаты is_enabled + - таблица отправленных новостей в комнаты: комната, subscription_id, ID отправленного сообщения (каждого, в RSS, например, возможны вставки) +5. сборка питон - с использованием uv +6. структура проекта + +``` +src/ + domain/ + subscriptions/ + entities.py — Subscription (комната, source, subscription_type, + interval_minutes, last_seen) + ports.py — SubscriptionRepository (add, update, remove, + list_by_room, get_due) + news/ + entities.py — NewsItem (id, title, link) + ports.py — NewsFetcher, NewsPublisher, SentNewsRepository + usecases.py — FetchAndPublishNews + admin/ + entities.py — Room (jid, admin_jid, is_enabled), Admin + ports.py — CommandResponder, RoomRepository, + CommandSymbolsRepository + usecases.py — HandleJoin, HandleExit, HandleSubscribe, + HandleUnsubscribe, HandleList, HandleListAll, + HandleCmd, HandleHelp + adapters/ + jabber/ + connection.py — XMPP-соединение (единственное, владелец бота) + news_publisher.py — реализует domain/news/ports.NewsPublisher + command_responder.py — реализует domain/admin/ports.CommandResponder + command_handler.py — входящий адаптер: слушает сообщения → + вызывает usecases admin + sources/ + rss/ + fetcher.py — реализует domain/news/ports.NewsFetcher + db/ + rooms.py — реализует domain/admin/ports.RoomRepository + subscriptions.py — реализует domain/subscriptions/ports.SubscriptionRepository + sent_news.py — реализует domain/news/ports.SentNewsRepository + command_symbols.py — реализует domain/admin/ports.CommandSymbolsRepository + (+ кэш в памяти) + scheduled/ + news_checker.py — входящий адаптер: таймер каждые 5 мин → + вызывает domain/news/usecases + main.py — composition root: сборка зависимостей, запуск бота +docs/ — документация для разработки +pyproject.toml — файл сборки uv +Dockerfile — файл с описанием контейнера +docker-compose.yaml — описание запуска контейнера +env.example — примеры секретов (настоящий .env подключается + в docker-compose.yaml) +``` + +Направления зависимостей: + domain/admin → domain/subscriptions + domain/news → domain/subscriptions + adapters/* → domain/* + main.py → adapters/* + domain/* + domain/admin ↛ domain/news + domain/news ↛ domain/admin + +ВАЖНО: важно реализовать модульность внутри кода. модульность подразумевает чистый код, возможность расширения (возможно, появится парсинг сайтов или трансляция из телеграм) + +7. бот каждые 5 минут должен инициировать проверку новостей (scheduled task запускается каждые 5 минут) - только для URL-based источников (телеграм - по-другому будет, когда будет) + +7.1. Алгоритм работы с RSS: + - собрать все RSS, которые должны быть опрошены - псевдокод для запроса: select * from subscriptions as subs left join rooms as room on subs.room_id = room.id where last_seen + check_interval::interval <= now() and room.is_enabled AND subs.subscription_type = 'rss' + - дедуплицировать RSS, чтобы не делать опросы дважды (из результата запроса на предыдущем пункте выбираем все distinct rss - используем set) + - опросить каждую RSS + - если в RSS появились новые сообщения, то отправить в комнаты сообщения + - если отправить в комнату не удалось по причине, что бот уже не в комнате (вдруг его удалили), то эта комната выключается в настройках и больше не участвует в сборе списка RSS для опроса + - после отправки каждого сообщения оно фиксируется в таблице отправленных новостей + - после отправки всех новостей из конкретной RSS в конкретную комнату в настройках обновляется время последней проверки + +7.2. Источники, отличные от RSS, будут добавлены позднее. + +8. в env.example необходимо указать JID_OWNER - это JID владельца бота, только ему будет доступна команда list-all + +### Уточнения технической реализации + +1. Таблица rooms — один или несколько админов на комнату? Схема: (комната, jid_админа) — допускает несколько записей на одну комнату. Что происходит если два разных JID выполнили join в одну комнату? Добавляются два админа, или второй join получает ошибку? + - на комнату один админ +2. subscribe на уже существующую RSS в комнате. Команда вызвана повторно, возможно с другим интервалом. Обновить интервал? Вернуть ошибку? Не описано. + - обновить интервал +3. last_seen для новой подписки ставить текущей датой now() + - поскольку таблица sent_news пустая, то ожидается, что при первом срабатывании scheduled задачи будет опубликовано много новостей.