Do PDF report

This commit is contained in:
Слонова Анна 2026-03-26 00:56:48 +03:00
parent 471e9bcc3b
commit 7ba74f3b51
6 changed files with 717 additions and 566 deletions

View file

@ -1,15 +1,15 @@
HOMESERVER=https://matrix.org HOMESERVER=https://matrix.org
# Bot's Matrix username (full MXID) # Bot's Matrix username (full MXID)
MATRIX_USERNAME=@your_bot:matrix.org MATRIX_USERNAME=@your_bot:matrix.org
# Either use password OR access token # Either use password OR access token
PASSWORD= PASSWORD=
ACCESS_TOKEN=syt_... ACCESS_TOKEN=syt_...
# Allowed rooms (comma-separated, no spaces) # Allowed rooms (comma-separated, no spaces)
ALLOWED_ROOMS=!roomid1:matrix.org,!roomid2:matrix.org ALLOWED_ROOMS=!roomid1:matrix.org,!roomid2:matrix.org
# Whisper settings # Whisper settings
WHISPER_LANGUAGE=ru WHISPER_LANGUAGE=ru
WHISPER_MODEL=small WHISPER_MODEL=small

344
.gitignore vendored
View file

@ -1,172 +1,172 @@
# ---> Python # ---> Python
# Byte-compiled / optimized / DLL files # Byte-compiled / optimized / DLL files
__pycache__/ __pycache__/
*.py[cod] *.py[cod]
*$py.class *$py.class
.idea/* .idea/*
.idea .idea
venv1 venv1
venv1/ venv1/
venv1/* venv1/*
venv1/** venv1/**
# C extensions # C extensions
*.so *.so
# Distribution / packaging # Distribution / packaging
.Python .Python
build/ build/
develop-eggs/ develop-eggs/
dist/ dist/
downloads/ downloads/
eggs/ eggs/
.eggs/ .eggs/
lib/ lib/
lib64/ lib64/
parts/ parts/
sdist/ sdist/
var/ var/
wheels/ wheels/
share/python-wheels/ share/python-wheels/
*.egg-info/ *.egg-info/
.installed.cfg .installed.cfg
*.egg *.egg
MANIFEST MANIFEST
# PyInstaller # PyInstaller
# Usually these files are written by a python script from a template # Usually these files are written by a python script from a template
# before PyInstaller builds the exe, so as to inject date/other infos into it. # before PyInstaller builds the exe, so as to inject date/other infos into it.
*.manifest *.manifest
*.spec *.spec
# Installer logs # Installer logs
pip-log.txt pip-log.txt
pip-delete-this-directory.txt pip-delete-this-directory.txt
# Unit test / coverage reports # Unit test / coverage reports
htmlcov/ htmlcov/
.tox/ .tox/
.nox/ .nox/
.coverage .coverage
.coverage.* .coverage.*
.cache .cache
nosetests.xml nosetests.xml
coverage.xml coverage.xml
*.cover *.cover
*.py,cover *.py,cover
.hypothesis/ .hypothesis/
.pytest_cache/ .pytest_cache/
cover/ cover/
# Translations # Translations
*.mo *.mo
*.pot *.pot
# Django stuff: # Django stuff:
*.log *.log
local_settings.py local_settings.py
db.sqlite3 db.sqlite3
db.sqlite3-journal db.sqlite3-journal
# Flask stuff: # Flask stuff:
instance/ instance/
.webassets-cache .webassets-cache
# Scrapy stuff: # Scrapy stuff:
.scrapy .scrapy
# Sphinx documentation # Sphinx documentation
docs/_build/ docs/_build/
# PyBuilder # PyBuilder
.pybuilder/ .pybuilder/
target/ target/
# Jupyter Notebook # Jupyter Notebook
.ipynb_checkpoints .ipynb_checkpoints
# IPython # IPython
profile_default/ profile_default/
ipython_config.py ipython_config.py
# pyenv # pyenv
# For a library or package, you might want to ignore these files since the code is # For a library or package, you might want to ignore these files since the code is
# intended to run in multiple environments; otherwise, check them in: # intended to run in multiple environments; otherwise, check them in:
# .python-version # .python-version
# pipenv # pipenv
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
# However, in case of collaboration, if having platform-specific dependencies or dependencies # However, in case of collaboration, if having platform-specific dependencies or dependencies
# having no cross-platform support, pipenv may install dependencies that don't work, or not # having no cross-platform support, pipenv may install dependencies that don't work, or not
# install all needed dependencies. # install all needed dependencies.
#Pipfile.lock #Pipfile.lock
# poetry # poetry
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
# This is especially recommended for binary packages to ensure reproducibility, and is more # This is especially recommended for binary packages to ensure reproducibility, and is more
# commonly ignored for libraries. # commonly ignored for libraries.
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
#poetry.lock #poetry.lock
# pdm # pdm
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
#pdm.lock #pdm.lock
# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
# in version control. # in version control.
# https://pdm.fming.dev/latest/usage/project/#working-with-version-control # https://pdm.fming.dev/latest/usage/project/#working-with-version-control
.pdm.toml .pdm.toml
.pdm-python .pdm-python
.pdm-build/ .pdm-build/
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
__pypackages__/ __pypackages__/
# Celery stuff # Celery stuff
celerybeat-schedule celerybeat-schedule
celerybeat.pid celerybeat.pid
# SageMath parsed files # SageMath parsed files
*.sage.py *.sage.py
# Environments # Environments
.env .env
.venv .venv
env/ env/
venv/ venv/
ENV/ ENV/
env.bak/ env.bak/
venv.bak/ venv.bak/
# Spyder project settings # Spyder project settings
.spyderproject .spyderproject
.spyproject .spyproject
# Rope project settings # Rope project settings
.ropeproject .ropeproject
# mkdocs documentation # mkdocs documentation
/site /site
# mypy # mypy
.mypy_cache/ .mypy_cache/
.dmypy.json .dmypy.json
dmypy.json dmypy.json
# Pyre type checker # Pyre type checker
.pyre/ .pyre/
# pytype static type analyzer # pytype static type analyzer
.pytype/ .pytype/
# Cython debug symbols # Cython debug symbols
cython_debug/ cython_debug/
# PyCharm # PyCharm
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can # JetBrains specific template is maintained in a separate JetBrains.gitignore that can
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
# and can be added to the global gitignore or merged into this file. For a more nuclear # and can be added to the global gitignore or merged into this file. For a more nuclear
# option (not recommended) you can uncomment the following to ignore the entire idea folder. # option (not recommended) you can uncomment the following to ignore the entire idea folder.
#.idea/ #.idea/

View file

@ -1,4 +1,4 @@
# Бот для автоматической генерации отчётов # Бот для автоматической генерации отчётов
Принимает фото/аудио файлы и текстовые сообщения, когда в течение 15 секунд нет новых сообщений, Принимает фото/аудио файлы и текстовые сообщения, когда в течение 15 секунд нет новых сообщений,
формирует отчёт по отправленному. формирует отчёт по отправленному.

18
base_prompt.txt Normal file
View file

@ -0,0 +1,18 @@
Твоя роль: сотрудник IT-компании.
Составь краткий отчёт о встрече на основе следующего текста.
Выдели ключевые темы, решения и действия, а также тезисно изложи суть и ход совещания.
Текст - расшифровка аудиозаписи встречи, и возможно, текстовые сообщения пользователя,
которому нужен отчёт.
Твой отчёт должен по стилю и содержанию вписываться в формат корпоративного отчёта
по прошедшей встрече, быть информативным, фиксировать все необходимые сведения, особенно те,
которые могут быть важны для дальнейших действий сотрудников.
НЕ ИСПОЛЬЗУЙ эмодзи. Твой отчёт должен содержать достаточный объём информации, отображая
все важные моменты. Вне зависимости от содержания полученных на вход данных оформи
ответ в формате html-страницы. Твой ответ ОБЯЗАТЕЛЬНО должен представлять собой html-страницу.
НЕ НУЖНО слишком сильно уходить в визуальное оформление отчёта: он должен быть удобен для
чтения и печати (в том числе) на ч/б принтере, должен быть оформлен аккуратно и легко для восприятия,
но не перегружено, в формате документа, основную смысловую часть которого составляет
текстовая информация. Размер шрифт должен быть не крупным, но удобно читаемым (около 12 пунктов).
Фон ВСЕГДА должен быть исключительно белым.
Если это требуется, ответ может быть достаточно большим по размеру. Не ограничивай себя в длине
ответа, но и ненужную информацию оставлять не нужно.

873
main.py
View file

@ -1,377 +1,496 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
import asyncio import asyncio
import os import os
import tempfile import tempfile
import time import aiofiles
import shutil import time
import subprocess import shutil
from typing import Dict, Optional, Tuple import subprocess
from dotenv import load_dotenv from weasyprint import HTML
import io
from nio import ( import json
AsyncClient, from typing import Dict, Optional, Tuple
RoomMessageText, from dotenv import load_dotenv
RoomMessageImage,
RoomMessageAudio, import aiohttp
LoginResponse, from nio import (
AsyncClientConfig, AsyncClient,
ErrorResponse, RoomMessageText,
) RoomMessageImage,
RoomMessageAudio,
from faster_whisper import WhisperModel LoginResponse,
AsyncClientConfig,
load_dotenv() ErrorResponse,
UploadResponse,
HOMESERVER = os.getenv("HOMESERVER", "https://matrix.org") UploadError,
USERNAME = os.getenv("MATRIX_USERNAME") )
PASSWORD = os.getenv("PASSWORD")
ALLOWED_ROOMS = set(room.strip() for room in os.getenv("ALLOWED_ROOMS", "").split(",") if room.strip()) from faster_whisper import WhisperModel
WHISPER_LANGUAGE = os.getenv("WHISPER_LANGUAGE", "ru")
WHISPER_MODEL = os.getenv("WHISPER_MODEL", "small") load_dotenv()
TEMP_DIR = tempfile.gettempdir() HOMESERVER = os.getenv("HOMESERVER", "https://matrix.org")
GROUPING_TIMEOUT = 15.0 USERNAME = os.getenv("MATRIX_USERNAME")
PASSWORD = os.getenv("PASSWORD")
client: AsyncClient = None ALLOWED_ROOMS = set(room.strip() for room in os.getenv("ALLOWED_ROOMS", "").split(",") if room.strip())
pending_by_conversation: Dict[Tuple[str, str], Dict] = {} WHISPER_LANGUAGE = os.getenv("WHISPER_LANGUAGE", "ru")
pending_by_event_id: Dict[str, Dict] = {} WHISPER_MODEL = os.getenv("WHISPER_MODEL", "small")
whisper_model = None # Qwen API
QWEN_API_KEY = os.getenv("QWEN_API_KEY")
QWEN_ENDPOINT = os.getenv("QWEN_ENDPOINT", "https://dashscope.aliyuncs.com/compatible-mode/v1/chat/completions")
def get_whisper_model(): QWEN_MODEL = os.getenv("QWEN_MODEL", "qwen3.5-122b")
global whisper_model QWEN_PROMPT_TEMPLATE = ""
if whisper_model is None: with open("base_prompt.txt", "r") as f:
whisper_model = WhisperModel(WHISPER_MODEL, device="cpu", compute_type="int8") QWEN_PROMPT_TEMPLATE += f.read()
print(f"Whisper модель {WHISPER_MODEL} загружена (faster-whisper).")
return whisper_model TEMP_DIR = tempfile.gettempdir()
GROUPING_TIMEOUT = 15.0
def ffmpeg_available() -> bool: client: AsyncClient = None
found = shutil.which("ffmpeg") is not None pending_by_conversation: Dict[Tuple[str, str], Dict] = {}
if not found: pending_by_event_id: Dict[str, Dict] = {}
print("[ERROR] ffmpeg не найден в системе. Установите ffmpeg и добавьте в PATH.")
return found whisper_model = None
def get_file_extension(mimetype: str) -> str: def get_whisper_model():
ext_map = { global whisper_model
"audio/ogg": ".ogg", if whisper_model is None:
"audio/mpeg": ".mp3", whisper_model = WhisperModel(WHISPER_MODEL, device="cpu", compute_type="int8")
"audio/mp4": ".m4a", print(f"Whisper модель {WHISPER_MODEL} загружена (faster-whisper).")
"audio/x-m4a": ".m4a", return whisper_model
"audio/wav": ".wav",
"audio/webm": ".webm",
} def ffmpeg_available() -> bool:
return ext_map.get(mimetype, ".tmp") found = shutil.which("ffmpeg") is not None
if not found:
print("[ERROR] ffmpeg не найден в системе. Установите ffmpeg и добавьте в PATH.")
async def convert_to_wav(input_path: str) -> Optional[str]: return found
output_fd, output_path = tempfile.mkstemp(suffix=".wav")
os.close(output_fd)
cmd = [ def get_file_extension(mimetype: str) -> str:
"ffmpeg", "-i", input_path, ext_map = {
"-map", "0:a:0", "audio/ogg": ".ogg",
"-map_metadata", "-1", "audio/mpeg": ".mp3",
"-vn", "audio/mp4": ".m4a",
"-acodec", "pcm_s16le", "audio/x-m4a": ".m4a",
"-ar", "16000", "audio/wav": ".wav",
"-ac", "1", "audio/webm": ".webm",
"-y", }
output_path return ext_map.get(mimetype, ".tmp")
]
try:
loop = asyncio.get_running_loop() async def convert_to_wav(input_path: str) -> Optional[str]:
await loop.run_in_executor(None, lambda: subprocess.run(cmd, capture_output=True, check=True)) output_fd, output_path = tempfile.mkstemp(suffix=".wav")
return output_path os.close(output_fd)
except subprocess.CalledProcessError as e: cmd = [
print(f"[AUDIO] Ошибка конвертации ffmpeg: {e.stderr.decode()}") "ffmpeg", "-i", input_path,
if os.path.exists(output_path): "-map", "0:a:0",
os.unlink(output_path) "-map_metadata", "-1",
return None "-vn",
"-acodec", "pcm_s16le",
"-ar", "16000",
async def transcribe_audio(audio_bytes: bytes, mimetype: str) -> Optional[str]: "-ac", "1",
if not ffmpeg_available(): "-y",
print("[AUDIO] Ошибка: ffmpeg не установлен.") output_path
return None ]
try:
ext = get_file_extension(mimetype) loop = asyncio.get_running_loop()
loop = asyncio.get_running_loop() await loop.run_in_executor(None, lambda: subprocess.run(cmd, capture_output=True, check=True))
with tempfile.NamedTemporaryFile(suffix=ext, delete=False) as tmp: return output_path
tmp.write(audio_bytes) except subprocess.CalledProcessError as e:
input_path = tmp.name print(f"[AUDIO] Ошибка конвертации ffmpeg: {e.stderr.decode()}")
if os.path.exists(output_path):
wav_path = None os.unlink(output_path)
try: return None
wav_path = await convert_to_wav(input_path)
if not wav_path:
print("[AUDIO] Конвертация в WAV не удалась.") async def transcribe_audio(audio_bytes: bytes, mimetype: str) -> Optional[str]:
return None if not ffmpeg_available():
print("[AUDIO] Ошибка: ffmpeg не установлен.")
model = get_whisper_model() return None
segments, info = await loop.run_in_executor(
None, ext = get_file_extension(mimetype)
lambda: model.transcribe(wav_path, beam_size=5, language=WHISPER_LANGUAGE) loop = asyncio.get_running_loop()
) with tempfile.NamedTemporaryFile(suffix=ext, delete=False) as tmp:
text = " ".join([segment.text for segment in segments]) tmp.write(audio_bytes)
return text.strip() input_path = tmp.name
except Exception as e:
print(f"[AUDIO] Ошибка при распознавании: {e}") wav_path = None
return None try:
finally: wav_path = await convert_to_wav(input_path)
if os.path.exists(input_path): if not wav_path:
os.unlink(input_path) print("[AUDIO] Конвертация в WAV не удалась.")
if wav_path and os.path.exists(wav_path): return None
os.unlink(wav_path)
model = get_whisper_model()
segments, info = await loop.run_in_executor(
async def process_audio(audio_data: Dict) -> str: None,
audio_bytes = audio_data["bytes"] lambda: model.transcribe(wav_path, beam_size=5, language=WHISPER_LANGUAGE)
mimetype = audio_data.get("mimetype", "audio/ogg") )
print(f"[AUDIO] Получено {len(audio_bytes)} байт аудио, тип: {mimetype}") text = " ".join([segment.text for segment in segments])
text = await transcribe_audio(audio_bytes, mimetype) return text.strip()
if text is None: except Exception as e:
print("[AUDIO] Распознавание не удалось.") print(f"[AUDIO] Ошибка при распознавании: {e}")
return "" return None
return text finally:
if os.path.exists(input_path):
os.unlink(input_path)
async def process_image(image_data: Dict) -> str: if wav_path and os.path.exists(wav_path):
print(f"[IMAGE] Получено {len(image_data['bytes'])} байт изображения") os.unlink(wav_path)
return "[Описание изображения будет добавлено позже]"
async def call_qwen_api(prompt: str) -> str:
async def generate_report(text: str, images_data: list, audios_data: list) -> str: """
audio_texts = [] Асинхронный вызов Qwen API для генерации отчёта.
for audio in audios_data: Возвращает текст ответа или сообщение об ошибке.
audio_text = await process_audio(audio) """
if audio_text: if not QWEN_API_KEY:
audio_texts.append(audio_text) print("[QWEN] API ключ не задан, возвращаем заглушку.")
return "API ключ Qwen не настроен. Отчёт не может быть сгенерирован."
image_descriptions = []
for img in images_data: headers = {
desc = await process_image(img) "Authorization": f"Bearer {QWEN_API_KEY}",
if desc: "Content-Type": "application/json"
image_descriptions.append(desc) }
payload = {
parts = [] "model": QWEN_MODEL,
if text: "messages": [
parts.append(f"**Текст сообщения:**\n{text}") {"role": "user", "content": prompt}
if audio_texts: ]
parts.append("**Распознанный текст из аудио:**\n" + "\n\n".join(audio_texts)) }
if image_descriptions:
parts.append("**Описания изображений:**\n" + "\n".join(image_descriptions)) try:
async with aiohttp.ClientSession() as session:
if not parts: async with session.post(QWEN_ENDPOINT, headers=headers, json=payload) as resp:
return "Не удалось обработать сообщение (нет текста, не распознано аудио или ошибка)." if resp.status == 200:
data = await resp.json()
print(f"[REPORT] text: {text}, images: {len(image_descriptions)}, audio: {len(audio_texts)}") if "choices" in data and len(data["choices"]) > 0:
return "\n\n".join(parts) content = data["choices"][0]["message"]["content"]
return content.strip()
else:
async def send_error_message(room_id: str, error_text: str): print(f"[QWEN] Неожиданный формат ответа: {data}")
await client.room_send( return "Ошибка: не удалось извлечь ответ из API."
room_id, else:
"m.room.message", text = await resp.text()
{"msgtype": "m.text", "body": f"{error_text}"} print(f"[QWEN] Ошибка API: {resp.status} - {text}")
) return f"Ошибка при обращении к Qwen API (HTTP {resp.status})."
except Exception as e:
print(f"[QWEN] Исключение: {e}")
async def process_complete_message(data: Dict): return "Не удалось соединиться с Qwen API."
room_id = data["room_id"]
# Объединяем все текстовые сообщения, которые были в этой группе
text_parts = data.get("text", []) async def generate_report(text: str, images_data: list, audios_data: list) -> Optional[bytes]:
text = "\n".join(text_parts) if text_parts else "" # Собираем транскрипции аудио
images_data = data.get("images", []) audio_texts = []
audios_data = data.get("audio", []) for audio in audios_data:
audio_text = await transcribe_audio(audio["bytes"], audio.get("mimetype", "audio/ogg"))
report = await generate_report(text, images_data, audios_data) if audio_text:
audio_texts.append(audio_text)
await client.room_send(
room_id, # Формируем полный текст для отчёта
"m.room.message", parts = []
{"msgtype": "m.text", "body": report} if text:
) parts.append(f"Текстовые сообщения:\n{text}")
if audio_texts:
if "event_id" in data: parts.append("Расшифровка аудио:\n" + "\n\n".join(audio_texts))
pending_by_event_id.pop(data["event_id"], None) if images_data:
pending_by_conversation.pop((room_id, data["sender"]), None) parts.append(f"Количество изображений: {len(images_data)} (анализ не выполнен)")
full_text = "\n\n".join(parts)
async def delayed_processing(data: Dict): if not full_text.strip():
await asyncio.sleep(GROUPING_TIMEOUT) return None
key = (data["room_id"], data["sender"])
if pending_by_conversation.get(key) is data: prompt = f"{QWEN_PROMPT_TEMPLATE}\n Текст: {full_text}"
await process_complete_message(data) print("[QWEN] Отправка запроса...")
report = await call_qwen_api(prompt)
print(f"[QWEN] Получен ответ: {report[:200]}...")
def get_or_create_pending(room_id: str, sender: str, event_id: Optional[str] = None) -> Dict:
if event_id and event_id in pending_by_event_id: # Если API вернул ошибку, не генерируем PDF
return pending_by_event_id[event_id] if report.startswith("Ошибка:"):
print(f"[QWEN] Ошибка API: {report}")
key = (room_id, sender) return None
if key in pending_by_conversation: report = report.replace('```html', '')
return pending_by_conversation[key] report = report.replace('```', '')
data = { try:
"room_id": room_id, pdf_bytes = HTML(string=report).write_pdf()
"sender": sender, return pdf_bytes
"text": [], # список строк, а не одна строка except Exception as e:
"images": [], print(f"[PDF] Ошибка генерации: {e}")
"audio": [], return None
"timestamp": time.time(),
"task": None,
} async def send_error_message(room_id: str, error_text: str):
if event_id: await client.room_send(
data["event_id"] = event_id room_id,
pending_by_conversation[key] = data "m.room.message",
if event_id: {"msgtype": "m.text", "body": f"{error_text}"}
pending_by_event_id[event_id] = data )
return data
async def process_audio(audio_data: Dict) -> str:
def reset_timer(data: Dict): audio_bytes = audio_data["bytes"]
if data["task"] and not data["task"].done(): mimetype = audio_data.get("mimetype", "audio/ogg")
data["task"].cancel() print(f"[AUDIO] Получено {len(audio_bytes)} байт аудио, тип: {mimetype}")
data["timestamp"] = time.time() text = await transcribe_audio(audio_bytes, mimetype)
data["task"] = asyncio.create_task(delayed_processing(data)) if text is None:
print("[AUDIO] Распознавание не удалось.")
return ""
async def on_text_message(room, event: RoomMessageText): return text
if event.sender == client.user_id:
return
if room.room_id not in ALLOWED_ROOMS: async def process_image(image_data: Dict) -> str:
return print(f"[IMAGE] Получено {len(image_data['bytes'])} байт изображения")
return "[Описание изображения будет добавлено позже]"
event_id = event.event_id
data = get_or_create_pending(room.room_id, event.sender, event_id)
# Добавляем текст в список, а не заменяем async def process_complete_message(data: Dict):
data["text"].append(event.body) room_id = data["room_id"]
reset_timer(data) text_parts = data.get("text", [])
print(f"[TEXT] Добавлен текст в сообщение от {event.sender}: {event.body}") text = "\n".join(text_parts) if text_parts else ""
images_data = data.get("images", [])
audios_data = data.get("audio", [])
async def on_image_message(room, event: RoomMessageImage):
if event.sender == client.user_id: pdf_bytes = await generate_report(text, images_data, audios_data)
return
if room.room_id not in ALLOWED_ROOMS: if pdf_bytes is None:
return await client.room_send(
room_id,
related_event_id = None "m.room.message",
if hasattr(event, "source") and "content" in event.source: {"msgtype": "m.text",
content = event.source["content"] "body": "Не удалось обработать сообщение (нет текста, не распознано аудио или ошибка)."}
if "m.relates_to" in content and "event_id" in content["m.relates_to"]: )
related_event_id = content["m.relates_to"]["event_id"] else:
print("[FILE] Загрузка файла на сервер...")
data = get_or_create_pending(room.room_id, event.sender, related_event_id) # Создаём файловый объект из байтов
file_like = io.BytesIO(pdf_bytes)
download_result = await client.download(event.url) upload_result = await client.upload(
if isinstance(download_result, ErrorResponse): file_like,
print(f"[IMAGE] Ошибка скачивания: {download_result.status_code} - {download_result.message}") content_type="application/pdf",
await send_error_message(room.room_id, "Не удалось загрузить изображение.") filename="report.pdf",
return filesize=len(pdf_bytes) # обязательно указываем размер
)
mimetype = getattr(event, "mimetype", None)
if not mimetype and hasattr(event, "info") and isinstance(event.info, dict): # Результат может быть кортежем (UploadError, None) или объектом UploadResponse
mimetype = event.info.get("mimetype") if isinstance(upload_result, tuple) and len(upload_result) > 0:
if not mimetype: result_obj = upload_result[0]
mimetype = "image/jpeg" else:
result_obj = upload_result
data["images"].append({
"bytes": download_result.body, if isinstance(result_obj, UploadError):
"mimetype": mimetype, print(f"[FILE] Ошибка загрузки: {result_obj.status_code} - {result_obj.message}")
}) await client.room_send(
reset_timer(data) room_id,
print(f"[IMAGE] Добавлено изображение в сообщение от {event.sender}") "m.room.message",
{"msgtype": "m.text", "body": "Не удалось загрузить отчёт на сервер."}
)
async def on_audio_message(room, event: RoomMessageAudio): elif isinstance(result_obj, UploadResponse):
if event.sender == client.user_id: mxc_url = result_obj.content_uri
return await client.room_send(
if room.room_id not in ALLOWED_ROOMS: room_id,
return "m.room.message",
{
related_event_id = None "msgtype": "m.file",
if hasattr(event, "source") and "content" in event.source: "body": "report.pdf",
content = event.source["content"] "url": mxc_url,
if "m.relates_to" in content and "event_id" in content["m.relates_to"]: "filename": "report.pdf",
related_event_id = content["m.relates_to"]["event_id"] "info": {
"mimetype": "application/pdf",
data = get_or_create_pending(room.room_id, event.sender, related_event_id) "size": len(pdf_bytes)
}
download_result = await client.download(event.url) }
if isinstance(download_result, ErrorResponse): )
print(f"[AUDIO] Ошибка скачивания: {download_result.status_code} - {download_result.message}") print("[FILE] PDF отправлен")
await send_error_message(room.room_id, "Не удалось загрузить аудио.") else:
return print(f"[FILE] Неизвестный тип ответа: {result_obj}")
await client.room_send(
mimetype = None room_id,
if hasattr(event, "info") and isinstance(event.info, dict): "m.room.message",
mimetype = event.info.get("mimetype") {"msgtype": "m.text", "body": "❌ Ошибка при загрузке отчёта (неизвестный ответ сервера)."}
if not mimetype: )
mimetype = "audio/ogg"
# Очистка данных
data["audio"].append({ if "event_id" in data:
"bytes": download_result.body, pending_by_event_id.pop(data["event_id"], None)
"mimetype": mimetype, pending_by_conversation.pop((room_id, data["sender"]), None)
})
reset_timer(data)
print(f"[AUDIO] Добавлено аудио в сообщение от {event.sender}") async def delayed_processing(data: Dict):
await asyncio.sleep(GROUPING_TIMEOUT)
key = (data["room_id"], data["sender"])
async def main(): if pending_by_conversation.get(key) is data:
global client await process_complete_message(data)
config = AsyncClientConfig(
max_timeouts=10, def get_or_create_pending(room_id: str, sender: str, event_id: Optional[str] = None) -> Dict:
store_sync_tokens=True, if event_id and event_id in pending_by_event_id:
encryption_enabled=False, return pending_by_event_id[event_id]
)
client = AsyncClient( key = (room_id, sender)
homeserver=HOMESERVER, if key in pending_by_conversation:
user=USERNAME, return pending_by_conversation[key]
device_id=None,
config=config, data = {
) "room_id": room_id,
"sender": sender,
try: "text": [],
if PASSWORD: "images": [],
response = await client.login(PASSWORD) "audio": [],
else: "timestamp": time.time(),
response = await client.login(token=os.environ.get("ACCESS_TOKEN", "")) "task": None,
}
if isinstance(response, LoginResponse): if event_id:
print(f"Бот {USERNAME} успешно авторизован на {HOMESERVER}") data["event_id"] = event_id
print(f"Access token: {client.access_token}") pending_by_conversation[key] = data
else: if event_id:
print(f"Ошибка авторизации: {response}") pending_by_event_id[event_id] = data
return return data
except Exception as e:
print(f"Исключение при авторизации: {e}")
return def reset_timer(data: Dict):
if data["task"] and not data["task"].done():
if not ffmpeg_available(): data["task"].cancel()
print("ВНИМАНИЕ: ffmpeg не найден. Бот не сможет распознавать аудио.") data["timestamp"] = time.time()
print("Установите ffmpeg (https://ffmpeg.org/download.html) и добавьте в PATH.") data["task"] = asyncio.create_task(delayed_processing(data))
else:
print("ffmpeg найден, аудио будет обрабатываться.")
async def on_text_message(room, event: RoomMessageText):
if WHISPER_LANGUAGE: if event.sender == client.user_id:
print(f"Язык распознавания: {WHISPER_LANGUAGE}") return
if room.room_id not in ALLOWED_ROOMS:
client.add_event_callback(on_text_message, RoomMessageText) return
client.add_event_callback(on_image_message, RoomMessageImage)
client.add_event_callback(on_audio_message, RoomMessageAudio) event_id = event.event_id
data = get_or_create_pending(room.room_id, event.sender, event_id)
print("Бот запущен, ожидание событий...") data["text"].append(event.body)
try: reset_timer(data)
await client.sync_forever(timeout=30000) print(f"[TEXT] Добавлен текст в сообщение от {event.sender}: {event.body}")
except KeyboardInterrupt:
print("Бот остановлен пользователем")
finally: async def on_image_message(room, event: RoomMessageImage):
await client.close() if event.sender == client.user_id:
return
if room.room_id not in ALLOWED_ROOMS:
if __name__ == "__main__": return
asyncio.run(main())
related_event_id = None
if hasattr(event, "source") and "content" in event.source:
content = event.source["content"]
if "m.relates_to" in content and "event_id" in content["m.relates_to"]:
related_event_id = content["m.relates_to"]["event_id"]
data = get_or_create_pending(room.room_id, event.sender, related_event_id)
download_result = await client.download(event.url)
if isinstance(download_result, ErrorResponse):
print(f"[IMAGE] Ошибка скачивания: {download_result.status_code} - {download_result.message}")
await send_error_message(room.room_id, "Не удалось загрузить изображение.")
return
mimetype = getattr(event, "mimetype", None)
if not mimetype and hasattr(event, "info") and isinstance(event.info, dict):
mimetype = event.info.get("mimetype")
if not mimetype:
mimetype = "image/jpeg"
data["images"].append({
"bytes": download_result.body,
"mimetype": mimetype,
})
reset_timer(data)
print(f"[IMAGE] Добавлено изображение в сообщение от {event.sender}")
async def on_audio_message(room, event: RoomMessageAudio):
if event.sender == client.user_id:
return
if room.room_id not in ALLOWED_ROOMS:
return
related_event_id = None
if hasattr(event, "source") and "content" in event.source:
content = event.source["content"]
if "m.relates_to" in content and "event_id" in content["m.relates_to"]:
related_event_id = content["m.relates_to"]["event_id"]
data = get_or_create_pending(room.room_id, event.sender, related_event_id)
download_result = await client.download(event.url)
if isinstance(download_result, ErrorResponse):
print(f"[AUDIO] Ошибка скачивания: {download_result.status_code} - {download_result.message}")
await send_error_message(room.room_id, "Не удалось загрузить аудио.")
return
mimetype = None
if hasattr(event, "info") and isinstance(event.info, dict):
mimetype = event.info.get("mimetype")
if not mimetype:
mimetype = "audio/ogg"
data["audio"].append({
"bytes": download_result.body,
"mimetype": mimetype,
})
reset_timer(data)
print(f"[AUDIO] Добавлено аудио в сообщение от {event.sender}")
async def main():
global client
config = AsyncClientConfig(
max_timeouts=10,
store_sync_tokens=True,
encryption_enabled=False,
)
client = AsyncClient(
homeserver=HOMESERVER,
user=USERNAME,
device_id=None,
config=config,
)
try:
if PASSWORD:
response = await client.login(PASSWORD)
else:
response = await client.login(token=os.environ.get("ACCESS_TOKEN", ""))
if isinstance(response, LoginResponse):
print(f"Бот {USERNAME} успешно авторизован на {HOMESERVER}")
else:
print(f"Ошибка авторизации: {response}")
return
except Exception as e:
print(f"Исключение при авторизации: {e}")
return
if not ffmpeg_available():
print("ВНИМАНИЕ: ffmpeg не найден. Бот не сможет распознавать аудио.")
print("Установите ffmpeg (https://ffmpeg.org/download.html) и добавьте в PATH.")
else:
print("ffmpeg найден, аудио будет обрабатываться.")
if WHISPER_LANGUAGE:
print(f"Язык распознавания: {WHISPER_LANGUAGE}")
if not QWEN_API_KEY:
print("ВНИМАНИЕ: QWEN_API_KEY не задан. Генерация отчётов будет недоступна.")
client.add_event_callback(on_text_message, RoomMessageText)
client.add_event_callback(on_image_message, RoomMessageImage)
client.add_event_callback(on_audio_message, RoomMessageAudio)
print("Бот запущен, ожидание событий...")
try:
await client.sync_forever(timeout=30000)
except KeyboardInterrupt:
print("Бот остановлен пользователем")
finally:
await client.close()
if __name__ == "__main__":
asyncio.run(main())

View file

@ -7,13 +7,17 @@ annotated-doc==0.0.4
anyio==4.12.1 anyio==4.12.1
attrs==26.1.0 attrs==26.1.0
av==17.0.0 av==17.0.0
brotli==1.2.0
certifi==2026.2.25 certifi==2026.2.25
cffi==2.0.0
click==8.3.1 click==8.3.1
cssselect2==0.9.0
ctranslate2==4.7.1 ctranslate2==4.7.1
dotenv==0.9.9 dotenv==0.9.9
faster-whisper==1.2.1 faster-whisper==1.2.1
filelock==3.25.2 filelock==3.25.2
flatbuffers==25.12.19 flatbuffers==25.12.19
fonttools==4.62.1
frozenlist==1.8.0 frozenlist==1.8.0
fsspec==2026.2.0 fsspec==2026.2.0
h11==0.16.0 h11==0.16.0
@ -27,6 +31,7 @@ hyperframe==6.1.0
idna==3.11 idna==3.11
jsonschema==4.26.0 jsonschema==4.26.0
jsonschema-specifications==2025.9.1 jsonschema-specifications==2025.9.1
Markdown==3.10.2
markdown-it-py==4.0.0 markdown-it-py==4.0.0
matrix-nio==0.25.2 matrix-nio==0.25.2
mdurl==0.1.2 mdurl==0.1.2
@ -35,10 +40,14 @@ multidict==6.7.1
numpy==2.4.3 numpy==2.4.3
onnxruntime==1.24.4 onnxruntime==1.24.4
packaging==26.0 packaging==26.0
pillow==12.1.1
propcache==0.4.1 propcache==0.4.1
protobuf==7.34.1 protobuf==7.34.1
pycparser==3.0
pycryptodome==3.23.0 pycryptodome==3.23.0
pydyf==0.12.1
Pygments==2.19.2 Pygments==2.19.2
pyphen==0.17.2
python-dotenv==1.2.2 python-dotenv==1.2.2
python-socks==2.8.1 python-socks==2.8.1
PyYAML==6.0.3 PyYAML==6.0.3
@ -48,9 +57,14 @@ rpds-py==0.30.0
setuptools==82.0.1 setuptools==82.0.1
shellingham==1.5.4 shellingham==1.5.4
sympy==1.14.0 sympy==1.14.0
tinycss2==1.5.1
tinyhtml5==2.1.0
tokenizers==0.22.2 tokenizers==0.22.2
tqdm==4.67.3 tqdm==4.67.3
typer==0.24.1 typer==0.24.1
typing_extensions==4.15.0 typing_extensions==4.15.0
unpaddedbase64==2.1.0 unpaddedbase64==2.1.0
weasyprint==68.1
webencodings==0.5.1
yarl==1.23.0 yarl==1.23.0
zopfli==0.4.1