From 8d36a62ca25140313c958e7b89997036a277fd18 Mon Sep 17 00:00:00 2001 From: ysv Date: Fri, 23 Oct 2026 10:54:54 +0300 Subject: [PATCH] init --- mcp/nntp/nntp_mcp.py | 371 ++++++++++++++++++++++++++++++++ skills/Kraft.Участники/SKILL.md | 30 +++ 2 files changed, 401 insertions(+) create mode 100644 mcp/nntp/nntp_mcp.py create mode 100644 skills/Kraft.Участники/SKILL.md diff --git a/mcp/nntp/nntp_mcp.py b/mcp/nntp/nntp_mcp.py new file mode 100644 index 0000000..8a8cd6d --- /dev/null +++ b/mcp/nntp/nntp_mcp.py @@ -0,0 +1,371 @@ +import warnings +# Полностью отключаем вывод любых предупреждений в консоль +warnings.filterwarnings("ignore", category=DeprecationWarning) +warnings.filterwarnings("ignore") + +import sys +import os +# Перенаправляем поток ошибок в пустоту, чтобы предупреждения не перехватывали stdio +sys.stderr = open(os.devnull, 'w') + + +import nntplib +import email +from email.policy import default +import re +import datetime +from email.header import decode_header +from email.utils import parsedate_to_datetime +from mcp.server.mcpserver import MCPServer + +# Инициализируем сервер +mcp = MCPServer("Bionic-NNTP-Search") + +NNTP_SERVER = "news.skysamara.ru" +PORT = 119 + +def parse_nntp_date(date_str: str) -> datetime.datetime: + """ + Конвертирует строку даты из заголовка NNTP в объект datetime с поддержкой UTC таймзоны. + """ + if not date_str: + return datetime.datetime.min.replace(tzinfo=datetime.timezone.utc) + try: + dt = parsedate_to_datetime(date_str) + if dt.tzinfo is None: + dt = dt.replace(tzinfo=datetime.timezone.utc) + return dt + except Exception: + return datetime.datetime.min.replace(tzinfo=datetime.timezone.utc) + +def clean_body_from_quotes(body_text: str) -> str: + """ + Принимает сырой текст письма и удаляет из него все строки цитирования (>), + а также вводные маркеры цитат. + """ + if not body_text: + return "" + + lines = body_text.splitlines() + clean_lines = [] + + intro_patterns = [ + r"on\s+.*?\s+wrote\s*:", + r"в\s+письме\s+от\s+.*?\s+вы\s+писали\s*:", + r"писал\s*\(а\)\s*:", + r"---.*?original\s+message.*?---" + ] + intro_regex = re.compile("|".join(intro_patterns), re.IGNORECASE) + + for line in lines: + stripped_line = line.strip() + + if stripped_line.startswith(">"): + continue + + if intro_regex.search(stripped_line): + continue + + if not stripped_line: + if clean_lines and clean_lines[-1] == "": + continue + + clean_lines.append(line) + + return "\n".join(clean_lines).strip() + +def decode_mime_header(header_value: str) -> str: + """Декодирует строку заголовка формата =?UTF-8?B?...?=""" + if not header_value: + return "" + try: + decoded_fragments = decode_header(header_value) + parts = [] + for text, encoding in decoded_fragments: + if isinstance(text, bytes): + enc = encoding if encoding else 'utf-8' + parts.append(text.decode(enc, errors='ignore')) + else: + parts.append(str(text)) + return "".join(parts) + except Exception: + return header_value + + +@mcp.tool() +def get_skysamara_groups() -> list[str]: + """ + Получить список доступных групп на сервере news.skysamara.ru. + """ + try: + groups = [] + with nntplib.NNTP(NNTP_SERVER, port=PORT, timeout=10) as s: + resp, group_list = s.list() + for group_info in group_list: + if group_info and len(group_info) > 0: + groups.append(group_info[0]) + return groups + except Exception as e: + return [f"Ошибка NNTP при получении групп: {e}"] + + +@mcp.tool() +def search_user_by_headers(user_email: str, group_name: str, depth: int = 1000) -> list[dict]: + """ + Ищет сообщения конкретного пользователя по автору (From) в указанной группе. + """ + try: + found_messages = [] + with nntplib.NNTP(NNTP_SERVER, port=PORT, timeout=10) as s: + resp, count, first, last, name = s.group(group_name) + + first_num = int(first) + last_num = int(last) + + start_msg = max(first_num, last_num - depth + 1) + if start_msg > last_num: + return [] + + resp, overviews = s.over((str(start_msg), str(last_num))) + + for msg_id, over in overviews: + raw_author = over.get('from', '') + raw_subject = over.get('subject', '') + + author = decode_mime_header(raw_author) + subject = decode_mime_header(raw_subject) + + if user_email.lower() in author.lower(): + found_messages.append({ + "msg_num": str(msg_id), + "subject": subject, + "author": author + }) + + return found_messages + except Exception as e: + return [{"error": f"Ошибка NNTP при поиске: {e}"}] + + +@mcp.tool() +def fetch_messages_by_date_range( + group_name: str, + start_date_iso: str, + end_date_iso: str, + max_count: int = 100 +) -> list[dict]: + """ + Извлекает сообщения из указанной группы за заданный временной интервал (YYYY-MM-DD). + """ + try: + start_dt = datetime.datetime.fromisoformat(start_date_iso).replace(tzinfo=datetime.timezone.utc) + end_dt = datetime.datetime.fromisoformat(end_date_iso).replace(tzinfo=datetime.timezone.utc) + extracted_messages = [] + + with nntplib.NNTP(NNTP_SERVER, port=PORT, timeout=15) as s: + resp, count, first, last, name = s.group(group_name) + first_num = int(first) + last_num = int(last) + + if first_num > last_num: + return [] + + resp, overviews = s.over((str(first_num), str(last_num))) + + for msg_id, over in reversed(overviews): + if len(extracted_messages) >= max_count: + break + + raw_date = over.get('date', '') + msg_dt = parse_nntp_date(raw_date) + + if msg_dt > end_dt: + continue + if msg_dt < start_dt: + break + + try: + _, info = s.article(str(msg_id)) + raw_bytes = b"\n".join(info.lines) + msg = email.message_from_bytes(raw_bytes, policy=default) + + body_text = "" + if msg.is_multipart(): + for part in msg.walk(): + if part.get_content_type() == "text/plain": + payload = part.get_payload(decode=True) + if payload: + body_text += payload.decode(part.get_content_charset() or 'utf-8', errors='ignore') + else: + payload = msg.get_payload(decode=True) + if payload: + body_text = payload.decode(msg.get_content_charset() or 'utf-8', errors='ignore') + except Exception: + continue + + extracted_messages.append({ + "msg_num": str(msg_id), + "date": msg_dt.isoformat(), + "from": decode_mime_header(over.get('from', '[Неизвестно]')), + "subject": decode_mime_header(over.get('subject', '[Без темы]')), + "body": clean_body_from_quotes(body_text) + }) + + return extracted_messages + except Exception as e: + return [{"error": f"Ошибка NNTP при фильтрации по датам: {e}"}] + + +@mcp.tool() +def fetch_message_by_id(msg_num_or_id: str, group_name: str = None) -> dict: + """ + Получает и полностью декодирует содержимое конкретного сообщения. + """ + try: + with nntplib.NNTP(NNTP_SERVER, port=PORT, timeout=10) as s: + is_message_id = str(msg_num_or_id).startswith("<") and str(msg_num_or_id).endswith(">") + + if not is_message_id: + if not group_name: + return {"error": "Для чтения сообщения по его номеру необходимо указать group_name"} + s.group(group_name) + + resp, info = s.article(msg_num_or_id) + raw_bytes = b"\n".join(info.lines) + msg = email.message_from_bytes(raw_bytes, policy=default) + + subject = decode_mime_header(msg.get("Subject", "[Без темы]")) + author = decode_mime_header(msg.get("From", "[Неизвестно]")) + date = msg.get("Date", "") + + body_text = "" + if msg.is_multipart(): + for part in msg.walk(): + content_type = part.get_content_type() + content_disposition = str(part.get("Content-Disposition")) + + if content_type == "text/plain" and "attachment" not in content_disposition: + payload = part.get_payload(decode=True) + if payload: + body_text += payload.decode(part.get_content_charset() or 'utf-8', errors='ignore') + else: + payload = msg.get_payload(decode=True) + if payload: + body_text = payload.decode(msg.get_content_charset() or 'utf-8', errors='ignore') + + if not body_text.strip(): + for part in msg.walk(): + if part.get_content_type() == "text/html": + payload = part.get_payload(decode=True) + if payload: + body_text = "[HTML Content]\n" + payload.decode(part.get_content_charset() or 'utf-8', errors='ignore') + break + + if not body_text.strip(): + body_text = "[Тело сообщения пустое или не содержит читаемого текста]" + + return { + "msg_id": msg_num_or_id, + "from": author, + "subject": subject, + "date": date, + "body": body_text + } + except Exception as e: + return {"error": f"Не удалось прочитать сообщение {msg_num_or_id}: {e}"} + + +@mcp.tool() +def get_user_pure_messages(group_name: str, user_email: str, depth: int = 50) -> list[dict]: + """ + Находит последние сообщения конкретного пользователя в группе и извлекает только его текст. + """ + pure_messages = [] + user_email_lower = user_email.lower() + + try: + with nntplib.NNTP(NNTP_SERVER, port=PORT, timeout=15) as s: + resp, count, first, last, name = s.group(group_name) + first_num = int(first) + last_num = int(last) + + start_msg = max(first_num, last_num - depth + 1) + if start_msg > last_num: + return [] + + resp, overviews = s.over((str(start_msg), str(last_num))) + + for msg_num, over in overviews: + author = decode_mime_header(over.get('from', '')) + + if user_email_lower not in author.lower(): + continue + + try: + _, info = s.article(str(msg_num)) + raw_bytes = b"\n".join(info.lines) + msg = email.message_from_bytes(raw_bytes, policy=default) + subject = decode_mime_header(msg.get("Subject", "[Без темы]")) + + body_text = "" + if msg.is_multipart(): + for part in msg.walk(): + if part.get_content_type() == "text/plain": + payload = part.get_payload(decode=True) + if payload: + body_text += payload.decode(part.get_content_charset() or 'utf-8', errors='ignore') + else: + payload = msg.get_payload(decode=True) + if payload: + body_text = payload.decode(msg.get_content_charset() or 'utf-8', errors='ignore') + except Exception: + continue + + pure_text = clean_body_from_quotes(body_text) + if pure_text: + pure_messages.append({ + "msg_num": str(msg_num), + "subject": subject, + "author": author, + "pure_text": pure_text + }) + + return pure_messages + except Exception as e: + return [{"error": f"Ошибка при извлечении сообщений пользователя: {e}"}] + +@mcp.tool() +def save_text_to_file(filename: str, content: str) -> str: + """ + Save text content into a local file. Supports absolute or relative paths. + + :param filename: Path or name of the file (e.g., 'C:/Users/ysv/Documents/bionic-project/chat-kraft/ghjtrnf/test.txt') + :param content: Text to write into the file + """ + try: + import os + + # Если передан просто test.txt, сохраняем в папку скрипта + # Если передан путь (абсолютный или относительный), работаем с ним + target_path = os.path.abspath(filename) + + # Автоматически создаем все промежуточные папки (например, ghjtrnf), если их еще нет на диске + target_dir = os.path.dirname(target_path) + if target_dir and not os.path.exists(target_dir): + os.makedirs(target_dir, exist_ok=True) + + with open(target_path, "w", encoding="utf-8") as f: + f.write(content) + + return f"SUCCESS: File saved at {target_path}" + except Exception as e: + return f"ERROR: {str(e)}" + + + +if __name__ == "__main__": + # Для современного пакета MCPServer (MCP v2.x) + # этот параметр автоматически развернет изолированный stdio-транспорт + # без конфликтов с внутренним вводом-выводом Bionic. + mcp.run(transport="stdio") + diff --git a/skills/Kraft.Участники/SKILL.md b/skills/Kraft.Участники/SKILL.md new file mode 100644 index 0000000..0365126 --- /dev/null +++ b/skills/Kraft.Участники/SKILL.md @@ -0,0 +1,30 @@ +--- +name: "Kraft.Участники" +description: "Анализ данных сообщений из заданной группы в определенный временной период с целью подсчета общего количества сообщений от каждого уникального участника." +--- + +## Входные данные (Inputs) +1. **`group_name`**: Название группы на сервере новостей (например, `kraft.talks`). +2. **`start_date`**: Начальная дата анализа в формате YYYY-MM-DD. +3. **`end_date`**: Конечная дата анализа в формате YYYY-MM-DD. + +## Логика работы (Workflow) +1. **Получение данных:** Используй функцию `fetch_messages_by_date_range(group_name, start_date, end_date)` для получения всех сообщений за указанный период и группу. +2. **Обработка:** Проанализируй полученный список сообщений (сообщения должны содержать поле `from` в формате "Имя "). +3. **Агрегация:** Создай словарь/таблицу, где ключом будет уникальный отправитель (используй полный email как основной идентификатор). Значением будет счетчик. +4. **Подсчет:** Для каждого сообщения из списка: + * Извлеки имя и email из поля `from`. + * Используй email для однозначной привязки к участнику. + * Увеличивай счетчик сообщений для этого уникального пользователя. +5. **Форматирование вывода:** Сформируй финальный отчет в виде таблицы со следующим заголовком и структурой: `"Имя | электронный адрес | количество сообщений"`. + +## Пример реализации (Mental Walkthrough) +Если получены N сообщений, нужно пройтись по ним один раз. Для каждого сообщения `msg`: +* Извлеки отправителя из `msg.from` (например, "Иван "). +* Сохрани или обнови запись в словаре: `"Ivan | ivan@mail.com": 1`. + +**Важно:** При выводе результатов используй уникальный email-адрес как основной идентификатор для предотвращения ошибок при одинаковых именах у разных пользователей. + +## Ожидаемый вывод +Четкий, легко читаемый и структурированный отчет в формате markdown table: +