serverus: DockerHost - установка Docker и ingress Caddy, deploy приложения с проверкой compose и caddy.conf до изменений и пересозданием только изменившихся сервисов, status, control, logs, remove. nftables перезапускается только при изменении конфига, Docker следом восстанавливает свои правила; release восстанавливает конфиг Debian вместо остановки nftables. Telegram: прием документов и отправка файлов, раздача файлов своего Bot API сервера через nginx (telegram-files). App: общая модель Managed для Point и Platform, DockerApp и версии файлов AppVersion, разбор архивов (uploads), сценарии платформы (platforms), задачи и меню docker-серверов в боте. Проверено на стенде Debian 13 с local_certs. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
115 lines
4.7 KiB
Python
115 lines
4.7 KiB
Python
"""Помощники для тестов приложений, которые строят интерфейсы ботов."""
|
|
from types import SimpleNamespace
|
|
|
|
from telebot import types
|
|
|
|
USER_ID = 7
|
|
CHAT_ID = 42
|
|
|
|
|
|
class FakeApi:
|
|
"""Подменяет TeleBot: запоминает отправленные и удаленные сообщения, отдает присланные документы."""
|
|
|
|
def __init__(self):
|
|
self.sent = []
|
|
self.deleted = []
|
|
self.documents = []
|
|
self.files = {}
|
|
"""file_id -> содержимое присланного документа."""
|
|
|
|
def get_file(self, file_id):
|
|
from .api import WORK_DIR, uses_own_server
|
|
|
|
# свой сервер отдает путь на своем диске, облако - относительный
|
|
path = f'{WORK_DIR}/1:token/documents/{file_id}' if uses_own_server() else f'documents/{file_id}'
|
|
return SimpleNamespace(file_id=file_id, file_path=path, file_size=len(self.files[file_id]))
|
|
|
|
def download_file(self, file_path):
|
|
return self.files[file_path.rsplit('/', 1)[-1]]
|
|
|
|
def send_document(self, chat_id, document, caption=None, reply_markup=None, parse_mode=None):
|
|
content = document.file.read()
|
|
self.documents.append(SimpleNamespace(
|
|
chat_id=chat_id, file_name=document.file_name, content=content, caption=caption, reply_markup=reply_markup,
|
|
))
|
|
self.sent.append(SimpleNamespace(chat_id=chat_id, text=caption, reply_markup=reply_markup, parse_mode=parse_mode))
|
|
return SimpleNamespace(message_id=len(self.sent))
|
|
|
|
def send_message(self, chat_id, text, reply_markup=None, parse_mode=None):
|
|
self.sent.append(SimpleNamespace(chat_id=chat_id, text=text, reply_markup=reply_markup, parse_mode=parse_mode))
|
|
return SimpleNamespace(message_id=len(self.sent))
|
|
|
|
def delete_message(self, chat_id, message_id):
|
|
self.deleted.append((chat_id, message_id))
|
|
|
|
@property
|
|
def last_text(self):
|
|
return self.sent[-1].text if self.sent else None
|
|
|
|
@property
|
|
def last_keyboard(self) -> list[list[str]] | None:
|
|
markup = self.sent[-1].reply_markup if self.sent else None
|
|
keyboard = getattr(markup, 'keyboard', None)
|
|
return [[button['text'] for button in row] for row in keyboard] if keyboard is not None else None
|
|
|
|
|
|
def message_update(update_id: int, text: str, chat_type: str = 'private', user_id: int = USER_ID) -> types.Update:
|
|
return types.Update.de_json(message_update_json(update_id, text, chat_type, user_id))
|
|
|
|
|
|
def message_update_json(update_id: int, text: str, chat_type: str = 'private', user_id: int = USER_ID) -> dict:
|
|
"""Обновление с сообщением в том виде, в каком его присылает Bot API."""
|
|
return {
|
|
'update_id': update_id,
|
|
'message': {
|
|
'message_id': update_id,
|
|
'date': 0,
|
|
'text': text,
|
|
'chat': {'id': CHAT_ID if user_id == USER_ID else user_id, 'type': chat_type,
|
|
'title': 'group' if chat_type != 'private' else None},
|
|
'from': {'id': user_id, 'is_bot': False, 'first_name': 'Ann', 'username': f'user{user_id}'},
|
|
},
|
|
}
|
|
|
|
|
|
def document_update_json(
|
|
update_id: int, file_id: str, file_name: str, size: int, caption: str = '', user_id: int = USER_ID,
|
|
) -> dict:
|
|
"""Обновление с документом в том виде, в каком его присылает Bot API."""
|
|
update = message_update_json(update_id, '', user_id=user_id)
|
|
message = update['message']
|
|
del message['text']
|
|
message['document'] = {'file_id': file_id, 'file_unique_id': file_id, 'file_name': file_name, 'file_size': size}
|
|
if caption:
|
|
message['caption'] = caption
|
|
return update
|
|
|
|
|
|
class Conversation:
|
|
"""Диалог пользователя с ботом: say() отправляет сообщение и возвращает ответ бота."""
|
|
|
|
def __init__(self, bot, user_id: int = USER_ID, api: FakeApi | None = None):
|
|
from .dispatcher import handle_update
|
|
|
|
self._handle_update = handle_update
|
|
self.bot = bot
|
|
self.user_id = user_id
|
|
self.api = api or FakeApi()
|
|
self._update_id = 0
|
|
|
|
def say(self, text: str) -> str | None:
|
|
self._update_id += 1
|
|
sent_before = len(self.api.sent)
|
|
self._handle_update(self.api, self.bot, message_update(self._update_id, text, user_id=self.user_id))
|
|
return self.api.last_text if len(self.api.sent) > sent_before else None
|
|
|
|
def send_document(self, file_name: str, content: bytes, caption: str = '') -> str | None:
|
|
"""Присылает боту документ и возвращает ответ бота."""
|
|
self._update_id += 1
|
|
file_id = f'file{self._update_id}'
|
|
self.api.files[file_id] = content
|
|
sent_before = len(self.api.sent)
|
|
update = document_update_json(self._update_id, file_id, file_name, len(content), caption, self.user_id)
|
|
self._handle_update(self.api, self.bot, types.Update.de_json(update))
|
|
return self.api.last_text if len(self.api.sent) > sent_before else None
|