test
This commit is contained in:
1 parent
367f024cbd
commit
5c9f382a7c
4 files changed
+833
-419
No files matched your search
@@ -0,0 +1,2 @@
|
|||||||
|
test
|
||||||
|
|
||||||
@@ -1,8 +1,9 @@
|
|||||||
{
|
{
|
||||||
"news.skysamara.ru/sky.test": {
|
"news.skysamara.ru/sky.test": {
|
||||||
"last_seen": null,
|
"last_seen": null,
|
||||||
"my_ids": [
|
"my_ids": [
|
||||||
"<179302383962.14856.2029190571264960217@test.com>"
|
"<179302383962.14856.2029190571264960217@test.com>",
|
||||||
]
|
"<179302423690.14856.15120990572980229485@test.com>"
|
||||||
}
|
]
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,412 @@
|
|||||||
|
"""
|
||||||
|
NNTP MCP-сервер: мониторинг новостных групп и ответы в тредах.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import warnings
|
||||||
|
warnings.filterwarnings("ignore")
|
||||||
|
|
||||||
|
import sys
|
||||||
|
import os
|
||||||
|
|
||||||
|
# stderr отправляем в лог-файл, чтобы предупреждения и трейсбэки
|
||||||
|
# не ломали stdio-транспорт, но ошибки оставались диагностируемыми
|
||||||
|
sys.stderr = open(os.path.join(os.path.dirname(os.path.abspath(__file__)), "nntp_mcp.log"), "a")
|
||||||
|
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
import datetime
|
||||||
|
import nntplib
|
||||||
|
import email
|
||||||
|
from email.message import EmailMessage
|
||||||
|
from email.header import decode_header
|
||||||
|
from email.utils import parseaddr, formataddr, make_msgid, formatdate, parsedate_to_datetime
|
||||||
|
from email.policy import default as email_policy
|
||||||
|
|
||||||
|
# Автоматическое переключение импорта в зависимости от окружения
|
||||||
|
try:
|
||||||
|
from mcp.server.fastmcp import FastMCP
|
||||||
|
mcp = FastMCP("nntp-monitor")
|
||||||
|
# Используем прокси-декоратор для единообразия регистрации ниже
|
||||||
|
def register_mcp_tool(func):
|
||||||
|
return mcp.tool()(func)
|
||||||
|
except ImportError:
|
||||||
|
from mcp.server.mcpserver import MCPServer
|
||||||
|
mcp = MCPServer("nntp-monitor")
|
||||||
|
def register_mcp_tool(func):
|
||||||
|
if hasattr(mcp, 'list_tools') and hasattr(mcp.list_tools, 'add_tool'):
|
||||||
|
mcp.list_tools.add_tool(func)
|
||||||
|
elif hasattr(mcp, 'register_tool'):
|
||||||
|
mcp.register_tool(func)
|
||||||
|
else:
|
||||||
|
# ИСПРАВЛЕНО: Правильный вызов декоратора со скобками для базового MCPServer
|
||||||
|
decorator = mcp.tool()
|
||||||
|
decorator(func)
|
||||||
|
return func
|
||||||
|
|
||||||
|
STATE_FILE = os.path.join(os.path.dirname(os.path.abspath(__file__)), "nntp_mcp_state.json")
|
||||||
|
DEFAULT_WINDOW = 1000
|
||||||
|
MAX_THREAD_MSGS = 40
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- состояние
|
||||||
|
|
||||||
|
def _state_key(server: str, group: str) -> str:
|
||||||
|
return f"{server.lower()}/{group.lower()}"
|
||||||
|
|
||||||
|
|
||||||
|
def _load_state() -> dict:
|
||||||
|
try:
|
||||||
|
with open(STATE_FILE, encoding="utf-8") as f:
|
||||||
|
return json.load(f)
|
||||||
|
except Exception:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
def _save_state(state: dict) -> None:
|
||||||
|
tmp = STATE_FILE + ".tmp"
|
||||||
|
with open(tmp, "w", encoding="utf-8") as f:
|
||||||
|
json.dump(state, f, ensure_ascii=False, indent=1)
|
||||||
|
os.replace(tmp, STATE_FILE)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- утилиты
|
||||||
|
|
||||||
|
def _decode(header_value) -> str:
|
||||||
|
if not header_value:
|
||||||
|
return ""
|
||||||
|
try:
|
||||||
|
parts = []
|
||||||
|
for text, enc in decode_header(str(header_value)):
|
||||||
|
if isinstance(text, bytes):
|
||||||
|
parts.append(text.decode(enc or "utf-8", errors="ignore"))
|
||||||
|
else:
|
||||||
|
parts.append(str(text))
|
||||||
|
return "".join(parts)
|
||||||
|
except Exception:
|
||||||
|
return str(header_value)
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_date(date_str: str) -> str:
|
||||||
|
if not date_str:
|
||||||
|
return ""
|
||||||
|
try:
|
||||||
|
dt = parsedate_to_datetime(date_str)
|
||||||
|
if dt.tzinfo is None:
|
||||||
|
dt = dt.replace(tzinfo=datetime.timezone.utc)
|
||||||
|
return dt.isoformat()
|
||||||
|
except Exception:
|
||||||
|
return str(date_str)
|
||||||
|
|
||||||
|
|
||||||
|
def _clean_quotes(body_text: str) -> str:
|
||||||
|
if not body_text:
|
||||||
|
return ""
|
||||||
|
intro = re.compile(
|
||||||
|
r"on\s+.*?\s+wrote\s*:|в\s+письме\s+от.*?:|писал\s*\(а\)\s*:|"
|
||||||
|
r"---.*?original\s+message.*?---",
|
||||||
|
re.IGNORECASE,
|
||||||
|
)
|
||||||
|
clean, prev_blank = [], False
|
||||||
|
for line in body_text.splitlines():
|
||||||
|
stripped = line.strip()
|
||||||
|
if stripped.startswith(">"):
|
||||||
|
continue
|
||||||
|
if intro.search(stripped):
|
||||||
|
continue
|
||||||
|
if not stripped and prev_blank:
|
||||||
|
continue
|
||||||
|
prev_blank = not stripped
|
||||||
|
clean.append(line)
|
||||||
|
return "\n".join(clean).strip()
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_body(msg) -> str:
|
||||||
|
body = ""
|
||||||
|
for part in (msg.walk() if msg.is_multipart() else [msg]):
|
||||||
|
if str(part.get("Content-Disposition", "")).lower().startswith("attachment"):
|
||||||
|
continue
|
||||||
|
if part.get_content_type() != "text/plain":
|
||||||
|
continue
|
||||||
|
payload = part.get_payload(decode=True)
|
||||||
|
if payload:
|
||||||
|
body += payload.decode(part.get_content_charset() or "utf-8", errors="ignore")
|
||||||
|
if not body.strip():
|
||||||
|
for part in (msg.walk() if msg.is_multipart() else [msg]):
|
||||||
|
if part.get_content_type() == "text/html":
|
||||||
|
payload = part.get_payload(decode=True)
|
||||||
|
if payload:
|
||||||
|
body = "[HTML]\n" + payload.decode(
|
||||||
|
part.get_content_charset() or "utf-8", errors="ignore")
|
||||||
|
break
|
||||||
|
return body or "[Тело сообщения пустое]"
|
||||||
|
|
||||||
|
|
||||||
|
def _connect(server: str, port: int, user: str, password: str):
|
||||||
|
if port == 563:
|
||||||
|
return nntplib.NNTP_SSL(server, port=port, timeout=30,
|
||||||
|
user=user or None, password=password or None)
|
||||||
|
return nntplib.NNTP(server, port=port, timeout=30,
|
||||||
|
user=user or None, password=password or None)
|
||||||
|
|
||||||
|
|
||||||
|
def _fetch_index(s, last: int, window: int) -> list[dict]:
|
||||||
|
start = max(1, last - window + 1)
|
||||||
|
try:
|
||||||
|
_, overviews = s.over((str(start), str(last)))
|
||||||
|
except Exception:
|
||||||
|
return []
|
||||||
|
index = []
|
||||||
|
for num, over in overviews:
|
||||||
|
msgid = (over.get("message-id") or "").strip() or f"<local-{num}>"
|
||||||
|
index.append({
|
||||||
|
"num": int(num),
|
||||||
|
"msgid": msgid,
|
||||||
|
"subject": _decode(over.get("subject", "")),
|
||||||
|
"from": _decode(over.get("from", "")),
|
||||||
|
"date": _parse_date(over.get("date", "")),
|
||||||
|
"refs": [r for r in (over.get("references") or "").split()
|
||||||
|
if r.startswith("<")],
|
||||||
|
})
|
||||||
|
index.sort(key=lambda m: m["num"])
|
||||||
|
return index
|
||||||
|
|
||||||
|
|
||||||
|
def _make_root_resolver(index_by_id: dict):
|
||||||
|
resolved = {}
|
||||||
|
|
||||||
|
def root_of(msg: dict) -> str:
|
||||||
|
chain, cur = [], msg
|
||||||
|
while True:
|
||||||
|
key = cur["msgid"]
|
||||||
|
if key in resolved:
|
||||||
|
r = resolved[key]
|
||||||
|
break
|
||||||
|
if key in chain:
|
||||||
|
r = key
|
||||||
|
break
|
||||||
|
chain.append(key)
|
||||||
|
parent_id = cur["refs"][0] if cur["refs"] else None
|
||||||
|
parent = index_by_id.get(parent_id) if parent_id else None
|
||||||
|
if parent is None:
|
||||||
|
r = parent_id or key
|
||||||
|
break
|
||||||
|
cur = parent
|
||||||
|
for k in chain:
|
||||||
|
resolved[k] = r
|
||||||
|
return r
|
||||||
|
|
||||||
|
return root_of
|
||||||
|
|
||||||
|
|
||||||
|
def _thread_members(index: list[dict], target: dict) -> list[dict]:
|
||||||
|
by_id = {m["msgid"]: m for m in index}
|
||||||
|
root_of = _make_root_resolver(by_id)
|
||||||
|
root = root_of(target)
|
||||||
|
members = [m for m in index if root_of(m) == root]
|
||||||
|
return members
|
||||||
|
|
||||||
|
|
||||||
|
def _fetch_bodies(s, members: list[dict]) -> list[dict]:
|
||||||
|
out = []
|
||||||
|
for m in members[-MAX_THREAD_MSGS:]:
|
||||||
|
entry = dict(m)
|
||||||
|
try:
|
||||||
|
_, info = s.article(str(m["num"]))
|
||||||
|
msg = email.message_from_bytes(b"\n".join(info.lines), policy=email_policy)
|
||||||
|
entry["body"] = _clean_quotes(_extract_body(msg))
|
||||||
|
except Exception as e:
|
||||||
|
entry["body"] = f"[Не удалось прочитать: {e}]"
|
||||||
|
out.append(entry)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- инструменты
|
||||||
|
|
||||||
|
def scan_group(server: str, group: str, port: int = 119,
|
||||||
|
user: str = "", password: str = "",
|
||||||
|
window: int = DEFAULT_WINDOW) -> dict:
|
||||||
|
"""
|
||||||
|
НАЙТИ НОВОЕ: сравнивает группу с отметкой прошлого запуска, собирает
|
||||||
|
затронутые треды целиком и возвращает их с контекстом.
|
||||||
|
"""
|
||||||
|
state = _load_state()
|
||||||
|
key = _state_key(server, group)
|
||||||
|
st = state.setdefault(key, {"last_seen": None, "my_ids": []})
|
||||||
|
my_ids = set(st.get("my_ids", []))
|
||||||
|
first_run = st["last_seen"] is None
|
||||||
|
|
||||||
|
try:
|
||||||
|
with _connect(server, port, user, password) as s:
|
||||||
|
_, count, first, last, _ = s.group(group)
|
||||||
|
first, last = int(first), int(last)
|
||||||
|
|
||||||
|
if first_run:
|
||||||
|
st["last_seen"] = max(first, last - 50)
|
||||||
|
since = st["last_seen"]
|
||||||
|
|
||||||
|
if last <= since:
|
||||||
|
_save_state(state)
|
||||||
|
return {"status": "no_new_messages", "group": group,
|
||||||
|
"last_seen": since, "last_num": last, "threads": []}
|
||||||
|
|
||||||
|
index = _fetch_index(s, last, window)
|
||||||
|
new_msgs = [m for m in index if m["num"] > since]
|
||||||
|
|
||||||
|
if not new_msgs:
|
||||||
|
st["last_seen"] = last
|
||||||
|
_save_state(state)
|
||||||
|
return {"status": "no_new_messages_in_window", "group": group,
|
||||||
|
"last_num": last, "threads": []}
|
||||||
|
|
||||||
|
threads = {}
|
||||||
|
for m in new_msgs:
|
||||||
|
members = _thread_members(index, m)
|
||||||
|
members_sorted = sorted(members, key=lambda x: x["num"])
|
||||||
|
root = members_sorted[0]["msgid"]
|
||||||
|
t = threads.setdefault(root, {"members": {}, "new": set()})
|
||||||
|
for mem in members_sorted:
|
||||||
|
t["members"][mem["msgid"]] = mem
|
||||||
|
t["new"].add(m["msgid"])
|
||||||
|
|
||||||
|
result_threads = []
|
||||||
|
for root, t in list(threads.items())[:10]:
|
||||||
|
members = sorted(t["members"].values(), key=lambda x: x["num"])
|
||||||
|
full = _fetch_bodies(s, members)
|
||||||
|
refs_all = {r for m in members for r in m["refs"]}
|
||||||
|
msg_ids = {m["msgid"] for m in members}
|
||||||
|
reply_to_me = bool(refs_all & my_ids) or bool(msg_ids & my_ids)
|
||||||
|
last_is_mine = members[-1]["msgid"] in my_ids
|
||||||
|
result_threads.append({
|
||||||
|
"root_msgid": root,
|
||||||
|
"subject": full[0]["subject"] or full[-1]["subject"],
|
||||||
|
"reply_to_me": reply_to_me,
|
||||||
|
"last_is_mine": last_is_mine,
|
||||||
|
"needs_reply": bool(t["new"] - msg_ids & my_ids) and not last_is_mine,
|
||||||
|
"new_msg_nums": sorted(
|
||||||
|
m["num"] for m in members if m["msgid"] in t["new"]),
|
||||||
|
"messages": [
|
||||||
|
{"num": m["num"], "msgid": m["msgid"],
|
||||||
|
"from": m["from"], "date": m["date"], "body": m["body"]}
|
||||||
|
for m in full
|
||||||
|
],
|
||||||
|
})
|
||||||
|
|
||||||
|
st["last_seen"] = last
|
||||||
|
_save_state(state)
|
||||||
|
return {"status": "ok", "group": group, "first_run": first_run,
|
||||||
|
"last_num": last, "threads": result_threads}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
return {"status": "error", "group": group, "error": str(e)}
|
||||||
|
|
||||||
|
|
||||||
|
def read_thread(server: str, group: str, msg_id: str, port: int = 119,
|
||||||
|
user: str = "", password: str = "",
|
||||||
|
window: int = DEFAULT_WINDOW) -> dict:
|
||||||
|
"""
|
||||||
|
ПРОЧИТАТЬ ВЕТКУ ЦЕЛИКОМ: msg_id — Message-ID (<...>) или номер статьи.
|
||||||
|
Возвращает все сообщения треда в хронологическом порядке с телами.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
with _connect(server, port, user, password) as s:
|
||||||
|
_, count, first, last, _ = s.group(group)
|
||||||
|
last_num = int(last)
|
||||||
|
|
||||||
|
try:
|
||||||
|
_, info = s.article(msg_id)
|
||||||
|
probe = email.message_from_bytes(b"\n".join(info.lines), policy=email_policy)
|
||||||
|
target_id = probe.get("Message-ID", msg_id)
|
||||||
|
except Exception as e:
|
||||||
|
return {"error": f"Сообщение {msg_id} не найдено: {e}"}
|
||||||
|
|
||||||
|
index = _fetch_index(s, last_num, window)
|
||||||
|
by_id = {m["msgid"]: m for m in index}
|
||||||
|
target = by_id.get(target_id)
|
||||||
|
if target is None:
|
||||||
|
return {"error": f"{target_id} вне окна сканирования, увеличьте window"}
|
||||||
|
|
||||||
|
members = _thread_members(index, target)
|
||||||
|
full = _fetch_bodies(s, members)
|
||||||
|
return {"thread_root": full[0]["msgid"],
|
||||||
|
"subject": full[0]["subject"],
|
||||||
|
"messages": [{"num": m["num"], "msgid": m["msgid"],
|
||||||
|
"from": m["from"], "date": m["date"],
|
||||||
|
"body": m["body"]} for m in full]}
|
||||||
|
except Exception as e:
|
||||||
|
return {"error": str(e)}
|
||||||
|
|
||||||
|
|
||||||
|
def post_reply(server: str, group: str, reply_to_msg_id: str, body: str,
|
||||||
|
sender_name: str, sender_email: str,
|
||||||
|
port: int = 119, user: str = "", password: str = "") -> dict:
|
||||||
|
"""
|
||||||
|
ОТВЕТИТЬ: постит ответ на сообщение reply_to_msg_id в группу.
|
||||||
|
Автоматически проставляет Re: в Subject, In-Reply-To и References.
|
||||||
|
"""
|
||||||
|
if not re.match(r"[^@\s]+@[^@\s]+", sender_email):
|
||||||
|
return {"error": "Некорректный sender_email"}
|
||||||
|
try:
|
||||||
|
with _connect(server, port, user, password) as s:
|
||||||
|
s.group(group)
|
||||||
|
try:
|
||||||
|
_, info = s.article(reply_to_msg_id)
|
||||||
|
except Exception as e:
|
||||||
|
return {"error": f"Не найдено сообщение {reply_to_msg_id}: {e}"}
|
||||||
|
|
||||||
|
parent = email.message_from_bytes(b"\n".join(info.lines), policy=email_policy)
|
||||||
|
parent_id = (parent.get("Message-ID") or reply_to_msg_id).strip()
|
||||||
|
parent_subject = _decode(parent.get("Subject", ""))
|
||||||
|
subject = parent_subject if re.match(r"(?i)^\s*(re|aw)\s*:", parent_subject) \
|
||||||
|
else f"Re: {parent_subject}"
|
||||||
|
|
||||||
|
refs = [r for r in (parent.get("References") or "").split()
|
||||||
|
if r.startswith("<")]
|
||||||
|
refs.append(parent_id)
|
||||||
|
|
||||||
|
msg = EmailMessage()
|
||||||
|
msg["From"] = formataddr((sender_name, sender_email))
|
||||||
|
msg["Newsgroups"] = group
|
||||||
|
msg["Subject"] = subject
|
||||||
|
msg["Date"] = formatdate(localtime=True)
|
||||||
|
msg["Message-ID"] = make_msgid(domain=sender_email.split("@")[-1])
|
||||||
|
msg["In-Reply-To"] = parent_id
|
||||||
|
msg["References"] = " ".join(refs)
|
||||||
|
msg.set_content(body, charset="utf-8")
|
||||||
|
|
||||||
|
data = msg.as_bytes(policy=email_policy)
|
||||||
|
resp = s.post(data.split(b"\n"))
|
||||||
|
|
||||||
|
state = _load_state()
|
||||||
|
st = state.setdefault(_state_key(server, group),
|
||||||
|
{"last_seen": None, "my_ids": []})
|
||||||
|
st.setdefault("my_ids", []).append(msg["Message-ID"])
|
||||||
|
st["my_ids"] = st["my_ids"][-200:]
|
||||||
|
_save_state(state)
|
||||||
|
|
||||||
|
return {"status": "posted", "message_id": msg["Message-ID"],
|
||||||
|
"subject": subject, "server_response": str(resp)}
|
||||||
|
except Exception as e:
|
||||||
|
return {"error": f"Ошибка отправки: {e}"}
|
||||||
|
|
||||||
|
|
||||||
|
def list_groups(server: str, port: int = 119,
|
||||||
|
user: str = "", password: str = "") -> list:
|
||||||
|
"""Диагностика: список групп на сервере."""
|
||||||
|
try:
|
||||||
|
with _connect(server, port, user, password) as s:
|
||||||
|
_, groups = s.list()
|
||||||
|
return [g for g in groups if g]
|
||||||
|
except Exception as e:
|
||||||
|
return [f"Ошибка: {e}"]
|
||||||
|
|
||||||
|
|
||||||
|
# Безопасная централизованная регистрация инструментов через прокси-функцию
|
||||||
|
scan_group = register_mcp_tool(scan_group)
|
||||||
|
read_thread = register_mcp_tool(read_thread)
|
||||||
|
post_reply = register_mcp_tool(post_reply)
|
||||||
|
list_groups = register_mcp_tool(list_groups)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
mcp.run(transport="stdio")
|
||||||
|
|
||||||
+411
-412
@@ -1,412 +1,411 @@
|
|||||||
"""
|
"""
|
||||||
NNTP MCP-сервер: мониторинг новостных групп и ответы в тредах.
|
NNTP MCP-сервер: мониторинг новостных групп и ответы в тредах.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import warnings
|
import warnings
|
||||||
warnings.filterwarnings("ignore")
|
warnings.filterwarnings("ignore")
|
||||||
|
|
||||||
import sys
|
import sys
|
||||||
import os
|
import os
|
||||||
|
|
||||||
# stderr отправляем в лог-файл, чтобы предупреждения и трейсбэки
|
# stderr отправляем в лог-файл, чтобы предупреждения и трейсбэки
|
||||||
# не ломали stdio-транспорт, но ошибки оставались диагностируемыми
|
# не ломали stdio-транспорт, но ошибки оставались диагностируемыми
|
||||||
sys.stderr = open(os.path.join(os.path.dirname(os.path.abspath(__file__)), "nntp_mcp.log"), "a")
|
sys.stderr = open(os.path.join(os.path.dirname(os.path.abspath(__file__)), "nntp_mcp.log"), "a")
|
||||||
|
|
||||||
import json
|
import json
|
||||||
import re
|
import re
|
||||||
import datetime
|
import datetime
|
||||||
import nntplib
|
import nntplib
|
||||||
import email
|
import email
|
||||||
from email.message import EmailMessage
|
from email.message import EmailMessage
|
||||||
from email.header import decode_header
|
from email.header import decode_header
|
||||||
from email.utils import parseaddr, formataddr, make_msgid, formatdate, parsedate_to_datetime
|
from email.utils import parseaddr, formataddr, make_msgid, formatdate, parsedate_to_datetime
|
||||||
from email.policy import default as email_policy
|
from email.policy import default as email_policy
|
||||||
|
|
||||||
# Автоматическое переключение импорта в зависимости от окружения
|
# Автоматическое переключение импорта в зависимости от окружения
|
||||||
try:
|
try:
|
||||||
from mcp.server.fastmcp import FastMCP
|
from mcp.server.fastmcp import FastMCP
|
||||||
mcp = FastMCP("nntp-monitor")
|
mcp = FastMCP("nntp-monitor")
|
||||||
# Используем прокси-декоратор для единообразия регистрации ниже
|
# Используем прокси-декоратор для единообразия регистрации ниже
|
||||||
def register_mcp_tool(func):
|
def register_mcp_tool(func):
|
||||||
return mcp.tool()(func)
|
return mcp.tool()(func)
|
||||||
except ImportError:
|
except ImportError:
|
||||||
from mcp.server.mcpserver import MCPServer
|
from mcp.server.mcpserver import MCPServer
|
||||||
mcp = MCPServer("nntp-monitor")
|
mcp = MCPServer("nntp-monitor")
|
||||||
def register_mcp_tool(func):
|
def register_mcp_tool(func):
|
||||||
if hasattr(mcp, 'list_tools') and hasattr(mcp.list_tools, 'add_tool'):
|
if hasattr(mcp, 'list_tools') and hasattr(mcp.list_tools, 'add_tool'):
|
||||||
mcp.list_tools.add_tool(func)
|
mcp.list_tools.add_tool(func)
|
||||||
elif hasattr(mcp, 'register_tool'):
|
elif hasattr(mcp, 'register_tool'):
|
||||||
mcp.register_tool(func)
|
mcp.register_tool(func)
|
||||||
else:
|
else:
|
||||||
# ИСПРАВЛЕНО: Правильный вызов декоратора со скобками для базового MCPServer
|
decorator = mcp.tool()
|
||||||
decorator = mcp.tool()
|
decorator(func)
|
||||||
decorator(func)
|
return func
|
||||||
return func
|
|
||||||
|
STATE_FILE = os.path.join(os.path.dirname(os.path.abspath(__file__)), "nntp_mcp_state.json")
|
||||||
STATE_FILE = os.path.join(os.path.dirname(os.path.abspath(__file__)), "nntp_mcp_state.json")
|
DEFAULT_SERVER = "news.skysamara.run"
|
||||||
DEFAULT_WINDOW = 1000
|
DEFAULT_PORT = 119
|
||||||
MAX_THREAD_MSGS = 40
|
DEFAULT_WINDOW = 1000
|
||||||
|
MAX_THREAD_MSGS = 40
|
||||||
|
|
||||||
# ---------------------------------------------------------------- состояние
|
|
||||||
|
# ---------------------------------------------------------------- состояние
|
||||||
def _state_key(server: str, group: str) -> str:
|
|
||||||
return f"{server.lower()}/{group.lower()}"
|
def _state_key(server: str, group: str) -> str:
|
||||||
|
return f"{server.lower()}/{group.lower()}"
|
||||||
|
|
||||||
def _load_state() -> dict:
|
|
||||||
try:
|
def _load_state() -> dict:
|
||||||
with open(STATE_FILE, encoding="utf-8") as f:
|
try:
|
||||||
return json.load(f)
|
with open(STATE_FILE, encoding="utf-8") as f:
|
||||||
except Exception:
|
return json.load(f)
|
||||||
return {}
|
except Exception:
|
||||||
|
return {}
|
||||||
|
|
||||||
def _save_state(state: dict) -> None:
|
|
||||||
tmp = STATE_FILE + ".tmp"
|
def _save_state(state: dict) -> None:
|
||||||
with open(tmp, "w", encoding="utf-8") as f:
|
tmp = STATE_FILE + ".tmp"
|
||||||
json.dump(state, f, ensure_ascii=False, indent=1)
|
with open(tmp, "w", encoding="utf-8") as f:
|
||||||
os.replace(tmp, STATE_FILE)
|
json.dump(state, f, ensure_ascii=False, indent=1)
|
||||||
|
os.replace(tmp, STATE_FILE)
|
||||||
|
|
||||||
# ---------------------------------------------------------------- утилиты
|
|
||||||
|
# ---------------------------------------------------------------- утилиты
|
||||||
def _decode(header_value) -> str:
|
|
||||||
if not header_value:
|
def _decode(header_value) -> str:
|
||||||
return ""
|
if not header_value:
|
||||||
try:
|
return ""
|
||||||
parts = []
|
try:
|
||||||
for text, enc in decode_header(str(header_value)):
|
parts = []
|
||||||
if isinstance(text, bytes):
|
for text, enc in decode_header(str(header_value)):
|
||||||
parts.append(text.decode(enc or "utf-8", errors="ignore"))
|
if isinstance(text, bytes):
|
||||||
else:
|
parts.append(text.decode(enc or "utf-8", errors="ignore"))
|
||||||
parts.append(str(text))
|
else:
|
||||||
return "".join(parts)
|
parts.append(str(text))
|
||||||
except Exception:
|
return "".join(parts)
|
||||||
return str(header_value)
|
except Exception:
|
||||||
|
return str(header_value)
|
||||||
|
|
||||||
def _parse_date(date_str: str) -> str:
|
|
||||||
if not date_str:
|
def _parse_date(date_str: str) -> str:
|
||||||
return ""
|
if not date_str:
|
||||||
try:
|
return ""
|
||||||
dt = parsedate_to_datetime(date_str)
|
try:
|
||||||
if dt.tzinfo is None:
|
dt = parsedate_to_datetime(date_str)
|
||||||
dt = dt.replace(tzinfo=datetime.timezone.utc)
|
if dt.tzinfo is None:
|
||||||
return dt.isoformat()
|
dt = dt.replace(tzinfo=datetime.timezone.utc)
|
||||||
except Exception:
|
return dt.isoformat()
|
||||||
return str(date_str)
|
except Exception:
|
||||||
|
return str(date_str)
|
||||||
|
|
||||||
def _clean_quotes(body_text: str) -> str:
|
|
||||||
if not body_text:
|
def _clean_quotes(body_text: str) -> str:
|
||||||
return ""
|
if not body_text:
|
||||||
intro = re.compile(
|
return ""
|
||||||
r"on\s+.*?\s+wrote\s*:|в\s+письме\s+от.*?:|писал\s*\(а\)\s*:|"
|
intro = re.compile(
|
||||||
r"---.*?original\s+message.*?---",
|
r"on\s+.*?\s+wrote\s*:|в\s+письме\s+от.*?:|писал\s*\(а\)\s*:|"
|
||||||
re.IGNORECASE,
|
r"---.*?original\s+message.*?---",
|
||||||
)
|
re.IGNORECASE,
|
||||||
clean, prev_blank = [], False
|
)
|
||||||
for line in body_text.splitlines():
|
clean, prev_blank = [], False
|
||||||
stripped = line.strip()
|
for line in body_text.splitlines():
|
||||||
if stripped.startswith(">"):
|
stripped = line.strip()
|
||||||
continue
|
if stripped.startswith(">"):
|
||||||
if intro.search(stripped):
|
continue
|
||||||
continue
|
if intro.search(stripped):
|
||||||
if not stripped and prev_blank:
|
continue
|
||||||
continue
|
if not stripped and prev_blank:
|
||||||
prev_blank = not stripped
|
continue
|
||||||
clean.append(line)
|
prev_blank = not stripped
|
||||||
return "\n".join(clean).strip()
|
clean.append(line)
|
||||||
|
return "\n".join(clean).strip()
|
||||||
|
|
||||||
def _extract_body(msg) -> str:
|
|
||||||
body = ""
|
def _extract_body(msg) -> str:
|
||||||
for part in (msg.walk() if msg.is_multipart() else [msg]):
|
body = ""
|
||||||
if str(part.get("Content-Disposition", "")).lower().startswith("attachment"):
|
for part in (msg.walk() if msg.is_multipart() else [msg]):
|
||||||
continue
|
if str(part.get("Content-Disposition", "")).lower().startswith("attachment"):
|
||||||
if part.get_content_type() != "text/plain":
|
continue
|
||||||
continue
|
if part.get_content_type() != "text/plain":
|
||||||
payload = part.get_payload(decode=True)
|
continue
|
||||||
if payload:
|
payload = part.get_payload(decode=True)
|
||||||
body += payload.decode(part.get_content_charset() or "utf-8", errors="ignore")
|
if payload:
|
||||||
if not body.strip():
|
body += payload.decode(part.get_content_charset() or "utf-8", errors="ignore")
|
||||||
for part in (msg.walk() if msg.is_multipart() else [msg]):
|
if not body.strip():
|
||||||
if part.get_content_type() == "text/html":
|
for part in (msg.walk() if msg.is_multipart() else [msg]):
|
||||||
payload = part.get_payload(decode=True)
|
if part.get_content_type() == "text/html":
|
||||||
if payload:
|
payload = part.get_payload(decode=True)
|
||||||
body = "[HTML]\n" + payload.decode(
|
if payload:
|
||||||
part.get_content_charset() or "utf-8", errors="ignore")
|
body = "[HTML]\n" + payload.decode(
|
||||||
break
|
part.get_content_charset() or "utf-8", errors="ignore")
|
||||||
return body or "[Тело сообщения пустое]"
|
break
|
||||||
|
return body or "[Тело сообщения пустое]"
|
||||||
|
|
||||||
def _connect(server: str, port: int, user: str, password: str):
|
|
||||||
if port == 563:
|
def _connect(server: str, port: int):
|
||||||
return nntplib.NNTP_SSL(server, port=port, timeout=30,
|
if port == 563:
|
||||||
user=user or None, password=password or None)
|
return nntplib.NNTP_SSL(server, port=port, timeout=30)
|
||||||
return nntplib.NNTP(server, port=port, timeout=30,
|
return nntplib.NNTP(server, port=port, timeout=30)
|
||||||
user=user or None, password=password or None)
|
|
||||||
|
|
||||||
|
def _fetch_index(s, last: int, window: int) -> list[dict]:
|
||||||
def _fetch_index(s, last: int, window: int) -> list[dict]:
|
start = max(1, last - window + 1)
|
||||||
start = max(1, last - window + 1)
|
try:
|
||||||
try:
|
_, overviews = s.over((str(start), str(last)))
|
||||||
_, overviews = s.over((str(start), str(last)))
|
except Exception:
|
||||||
except Exception:
|
return []
|
||||||
return []
|
index = []
|
||||||
index = []
|
for num, over in overviews:
|
||||||
for num, over in overviews:
|
msgid = (over.get("message-id") or "").strip() or f"<local-{num}>"
|
||||||
msgid = (over.get("message-id") or "").strip() or f"<local-{num}>"
|
index.append({
|
||||||
index.append({
|
"num": int(num),
|
||||||
"num": int(num),
|
"msgid": msgid,
|
||||||
"msgid": msgid,
|
"subject": _decode(over.get("subject", "")),
|
||||||
"subject": _decode(over.get("subject", "")),
|
"from": _decode(over.get("from", "")),
|
||||||
"from": _decode(over.get("from", "")),
|
"date": _parse_date(over.get("date", "")),
|
||||||
"date": _parse_date(over.get("date", "")),
|
"refs": [r for r in (over.get("references") or "").split()
|
||||||
"refs": [r for r in (over.get("references") or "").split()
|
if r.startswith("<")],
|
||||||
if r.startswith("<")],
|
})
|
||||||
})
|
index.sort(key=lambda m: m["num"])
|
||||||
index.sort(key=lambda m: m["num"])
|
return index
|
||||||
return index
|
|
||||||
|
|
||||||
|
def _make_root_resolver(index_by_id: dict):
|
||||||
def _make_root_resolver(index_by_id: dict):
|
resolved = {}
|
||||||
resolved = {}
|
|
||||||
|
def root_of(msg: dict) -> str:
|
||||||
def root_of(msg: dict) -> str:
|
chain, cur = [], msg
|
||||||
chain, cur = [], msg
|
while True:
|
||||||
while True:
|
key = cur["msgid"]
|
||||||
key = cur["msgid"]
|
if key in resolved:
|
||||||
if key in resolved:
|
r = resolved[key]
|
||||||
r = resolved[key]
|
break
|
||||||
break
|
if key in chain:
|
||||||
if key in chain:
|
r = key
|
||||||
r = key
|
break
|
||||||
break
|
chain.append(key)
|
||||||
chain.append(key)
|
parent_id = cur["refs"][0] if cur["refs"] else None
|
||||||
parent_id = cur["refs"][0] if cur["refs"] else None
|
parent = index_by_id.get(parent_id) if parent_id else None
|
||||||
parent = index_by_id.get(parent_id) if parent_id else None
|
if parent is None:
|
||||||
if parent is None:
|
r = parent_id or key
|
||||||
r = parent_id or key
|
break
|
||||||
break
|
cur = parent
|
||||||
cur = parent
|
for k in chain:
|
||||||
for k in chain:
|
resolved[k] = r
|
||||||
resolved[k] = r
|
return r
|
||||||
return r
|
|
||||||
|
return root_of
|
||||||
return root_of
|
|
||||||
|
|
||||||
|
def _thread_members(index: list[dict], target: dict) -> list[dict]:
|
||||||
def _thread_members(index: list[dict], target: dict) -> list[dict]:
|
by_id = {m["msgid"]: m for m in index}
|
||||||
by_id = {m["msgid"]: m for m in index}
|
root_of = _make_root_resolver(by_id)
|
||||||
root_of = _make_root_resolver(by_id)
|
root = root_of(target)
|
||||||
root = root_of(target)
|
members = [m for m in index if root_of(m) == root]
|
||||||
members = [m for m in index if root_of(m) == root]
|
return members
|
||||||
return members
|
|
||||||
|
|
||||||
|
def _fetch_bodies(s, members: list[dict]) -> list[dict]:
|
||||||
def _fetch_bodies(s, members: list[dict]) -> list[dict]:
|
out = []
|
||||||
out = []
|
for m in members[-MAX_THREAD_MSGS:]:
|
||||||
for m in members[-MAX_THREAD_MSGS:]:
|
entry = dict(m)
|
||||||
entry = dict(m)
|
try:
|
||||||
try:
|
_, info = s.article(str(m["num"]))
|
||||||
_, info = s.article(str(m["num"]))
|
msg = email.message_from_bytes(b"\n".join(info.lines), policy=email_policy)
|
||||||
msg = email.message_from_bytes(b"\n".join(info.lines), policy=email_policy)
|
entry["body"] = _clean_quotes(_extract_body(msg))
|
||||||
entry["body"] = _clean_quotes(_extract_body(msg))
|
except Exception as e:
|
||||||
except Exception as e:
|
entry["body"] = f"[Не удалось прочитать: {e}]"
|
||||||
entry["body"] = f"[Не удалось прочитать: {e}]"
|
out.append(entry)
|
||||||
out.append(entry)
|
return out
|
||||||
return out
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- инструменты
|
||||||
# ---------------------------------------------------------------- инструменты
|
|
||||||
|
def scan_group(server: str = DEFAULT_SERVER, group: str = "",
|
||||||
def scan_group(server: str, group: str, port: int = 119,
|
port: int = DEFAULT_PORT,
|
||||||
user: str = "", password: str = "",
|
window: int = DEFAULT_WINDOW) -> dict:
|
||||||
window: int = DEFAULT_WINDOW) -> dict:
|
"""
|
||||||
"""
|
НАЙТИ НОВОЕ: сравнивает группу с отметкой прошлого запуска, собирает
|
||||||
НАЙТИ НОВОЕ: сравнивает группу с отметкой прошлого запуска, собирает
|
затронутые треды целиком и возвращает их с контекстом.
|
||||||
затронутые треды целиком и возвращает их с контекстом.
|
"""
|
||||||
"""
|
state = _load_state()
|
||||||
state = _load_state()
|
key = _state_key(server, group)
|
||||||
key = _state_key(server, group)
|
st = state.setdefault(key, {"last_seen": None, "my_ids": []})
|
||||||
st = state.setdefault(key, {"last_seen": None, "my_ids": []})
|
my_ids = set(st.get("my_ids", []))
|
||||||
my_ids = set(st.get("my_ids", []))
|
first_run = st["last_seen"] is None
|
||||||
first_run = st["last_seen"] is None
|
|
||||||
|
try:
|
||||||
try:
|
with _connect(server, port) as s:
|
||||||
with _connect(server, port, user, password) as s:
|
_, count, first, last, _ = s.group(group)
|
||||||
_, count, first, last, _ = s.group(group)
|
first, last = int(first), int(last)
|
||||||
first, last = int(first), int(last)
|
|
||||||
|
if first_run:
|
||||||
if first_run:
|
st["last_seen"] = max(first, last - 50)
|
||||||
st["last_seen"] = max(first, last - 50)
|
since = st["last_seen"]
|
||||||
since = st["last_seen"]
|
|
||||||
|
if last <= since:
|
||||||
if last <= since:
|
_save_state(state)
|
||||||
_save_state(state)
|
return {"status": "no_new_messages", "group": group,
|
||||||
return {"status": "no_new_messages", "group": group,
|
"last_seen": since, "last_num": last, "threads": []}
|
||||||
"last_seen": since, "last_num": last, "threads": []}
|
|
||||||
|
index = _fetch_index(s, last, window)
|
||||||
index = _fetch_index(s, last, window)
|
new_msgs = [m for m in index if m["num"] > since]
|
||||||
new_msgs = [m for m in index if m["num"] > since]
|
|
||||||
|
if not new_msgs:
|
||||||
if not new_msgs:
|
st["last_seen"] = last
|
||||||
st["last_seen"] = last
|
_save_state(state)
|
||||||
_save_state(state)
|
return {"status": "no_new_messages_in_window", "group": group,
|
||||||
return {"status": "no_new_messages_in_window", "group": group,
|
"last_num": last, "threads": []}
|
||||||
"last_num": last, "threads": []}
|
|
||||||
|
threads = {}
|
||||||
threads = {}
|
for m in new_msgs:
|
||||||
for m in new_msgs:
|
members = _thread_members(index, m)
|
||||||
members = _thread_members(index, m)
|
members_sorted = sorted(members, key=lambda x: x["num"])
|
||||||
members_sorted = sorted(members, key=lambda x: x["num"])
|
root = members_sorted[0]["msgid"]
|
||||||
root = members_sorted[0]["msgid"]
|
t = threads.setdefault(root, {"members": {}, "new": set()})
|
||||||
t = threads.setdefault(root, {"members": {}, "new": set()})
|
for mem in members_sorted:
|
||||||
for mem in members_sorted:
|
t["members"][mem["msgid"]] = mem
|
||||||
t["members"][mem["msgid"]] = mem
|
t["new"].add(m["msgid"])
|
||||||
t["new"].add(m["msgid"])
|
|
||||||
|
result_threads = []
|
||||||
result_threads = []
|
for root, t in list(threads.items())[:10]:
|
||||||
for root, t in list(threads.items())[:10]:
|
members = sorted(t["members"].values(), key=lambda x: x["num"])
|
||||||
members = sorted(t["members"].values(), key=lambda x: x["num"])
|
full = _fetch_bodies(s, members)
|
||||||
full = _fetch_bodies(s, members)
|
refs_all = {r for m in members for r in m["refs"]}
|
||||||
refs_all = {r for m in members for r in m["refs"]}
|
msg_ids = {m["msgid"] for m in members}
|
||||||
msg_ids = {m["msgid"] for m in members}
|
reply_to_me = bool(refs_all & my_ids) or bool(msg_ids & my_ids)
|
||||||
reply_to_me = bool(refs_all & my_ids) or bool(msg_ids & my_ids)
|
last_is_mine = members[-1]["msgid"] in my_ids
|
||||||
last_is_mine = members[-1]["msgid"] in my_ids
|
result_threads.append({
|
||||||
result_threads.append({
|
"root_msgid": root,
|
||||||
"root_msgid": root,
|
"subject": full[0]["subject"] or full[-1]["subject"],
|
||||||
"subject": full[0]["subject"] or full[-1]["subject"],
|
"reply_to_me": reply_to_me,
|
||||||
"reply_to_me": reply_to_me,
|
"last_is_mine": last_is_mine,
|
||||||
"last_is_mine": last_is_mine,
|
"needs_reply": bool(t["new"] - msg_ids & my_ids) and not last_is_mine,
|
||||||
"needs_reply": bool(t["new"] - msg_ids & my_ids) and not last_is_mine,
|
"new_msg_nums": sorted(
|
||||||
"new_msg_nums": sorted(
|
m["num"] for m in members if m["msgid"] in t["new"]),
|
||||||
m["num"] for m in members if m["msgid"] in t["new"]),
|
"messages": [
|
||||||
"messages": [
|
{"num": m["num"], "msgid": m["msgid"],
|
||||||
{"num": m["num"], "msgid": m["msgid"],
|
"from": m["from"], "date": m["date"], "body": m["body"]}
|
||||||
"from": m["from"], "date": m["date"], "body": m["body"]}
|
for m in full
|
||||||
for m in full
|
],
|
||||||
],
|
})
|
||||||
})
|
|
||||||
|
st["last_seen"] = last
|
||||||
st["last_seen"] = last
|
_save_state(state)
|
||||||
_save_state(state)
|
return {"status": "ok", "group": group, "first_run": first_run,
|
||||||
return {"status": "ok", "group": group, "first_run": first_run,
|
"last_num": last, "threads": result_threads}
|
||||||
"last_num": last, "threads": result_threads}
|
|
||||||
|
except Exception as e:
|
||||||
except Exception as e:
|
return {"status": "error", "group": group, "error": str(e)}
|
||||||
return {"status": "error", "group": group, "error": str(e)}
|
|
||||||
|
|
||||||
|
def read_thread(server: str = DEFAULT_SERVER, group: str = "",
|
||||||
def read_thread(server: str, group: str, msg_id: str, port: int = 119,
|
msg_id: str = "", port: int = DEFAULT_PORT,
|
||||||
user: str = "", password: str = "",
|
window: int = DEFAULT_WINDOW) -> dict:
|
||||||
window: int = DEFAULT_WINDOW) -> dict:
|
"""
|
||||||
"""
|
ПРОЧИТАТЬ ВЕТКУ ЦЕЛИКОМ: msg_id — Message-ID (<...>) или номер статьи.
|
||||||
ПРОЧИТАТЬ ВЕТКУ ЦЕЛИКОМ: msg_id — Message-ID (<...>) или номер статьи.
|
Возвращает все сообщения треда в хронологическом порядке с телами.
|
||||||
Возвращает все сообщения треда в хронологическом порядке с телами.
|
"""
|
||||||
"""
|
try:
|
||||||
try:
|
with _connect(server, port) as s:
|
||||||
with _connect(server, port, user, password) as s:
|
_, count, first, last, _ = s.group(group)
|
||||||
_, count, first, last, _ = s.group(group)
|
last_num = int(last)
|
||||||
last_num = int(last)
|
|
||||||
|
try:
|
||||||
try:
|
_, info = s.article(msg_id)
|
||||||
_, info = s.article(msg_id)
|
probe = email.message_from_bytes(b"\n".join(info.lines), policy=email_policy)
|
||||||
probe = email.message_from_bytes(b"\n".join(info.lines), policy=email_policy)
|
target_id = probe.get("Message-ID", msg_id)
|
||||||
target_id = probe.get("Message-ID", msg_id)
|
except Exception as e:
|
||||||
except Exception as e:
|
return {"error": f"Сообщение {msg_id} не найдено: {e}"}
|
||||||
return {"error": f"Сообщение {msg_id} не найдено: {e}"}
|
|
||||||
|
index = _fetch_index(s, last_num, window)
|
||||||
index = _fetch_index(s, last_num, window)
|
by_id = {m["msgid"]: m for m in index}
|
||||||
by_id = {m["msgid"]: m for m in index}
|
target = by_id.get(target_id)
|
||||||
target = by_id.get(target_id)
|
if target is None:
|
||||||
if target is None:
|
return {"error": f"{target_id} вне окна сканирования, увеличьте window"}
|
||||||
return {"error": f"{target_id} вне окна сканирования, увеличьте window"}
|
|
||||||
|
members = _thread_members(index, target)
|
||||||
members = _thread_members(index, target)
|
full = _fetch_bodies(s, members)
|
||||||
full = _fetch_bodies(s, members)
|
return {"thread_root": full[0]["msgid"],
|
||||||
return {"thread_root": full[0]["msgid"],
|
"subject": full[0]["subject"],
|
||||||
"subject": full[0]["subject"],
|
"messages": [{"num": m["num"], "msgid": m["msgid"],
|
||||||
"messages": [{"num": m["num"], "msgid": m["msgid"],
|
"from": m["from"], "date": m["date"],
|
||||||
"from": m["from"], "date": m["date"],
|
"body": m["body"]} for m in full]}
|
||||||
"body": m["body"]} for m in full]}
|
except Exception as e:
|
||||||
except Exception as e:
|
return {"error": str(e)}
|
||||||
return {"error": str(e)}
|
|
||||||
|
|
||||||
|
def post_reply(server: str = DEFAULT_SERVER, group: str = "",
|
||||||
def post_reply(server: str, group: str, reply_to_msg_id: str, body: str,
|
reply_to_msg_id: str = "", body: str = "",
|
||||||
sender_name: str, sender_email: str,
|
sender_name: str = "", sender_email: str = "",
|
||||||
port: int = 119, user: str = "", password: str = "") -> dict:
|
port: int = DEFAULT_PORT) -> dict:
|
||||||
"""
|
"""
|
||||||
ОТВЕТИТЬ: постит ответ на сообщение reply_to_msg_id в группу.
|
ОТВЕТИТЬ: постит ответ на сообщение reply_to_msg_id в группу.
|
||||||
Автоматически проставляет Re: в Subject, In-Reply-To и References.
|
Автоматически проставляет Re: в Subject, In-Reply-To и References.
|
||||||
"""
|
"""
|
||||||
if not re.match(r"[^@\s]+@[^@\s]+", sender_email):
|
if not re.match(r"[^@\s]+@[^@\s]+", sender_email):
|
||||||
return {"error": "Некорректный sender_email"}
|
return {"error": "Некорректный sender_email"}
|
||||||
try:
|
try:
|
||||||
with _connect(server, port, user, password) as s:
|
with _connect(server, port) as s:
|
||||||
s.group(group)
|
s.group(group)
|
||||||
try:
|
try:
|
||||||
_, info = s.article(reply_to_msg_id)
|
_, info = s.article(reply_to_msg_id)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return {"error": f"Не найдено сообщение {reply_to_msg_id}: {e}"}
|
return {"error": f"Не найдено сообщение {reply_to_msg_id}: {e}"}
|
||||||
|
|
||||||
parent = email.message_from_bytes(b"\n".join(info.lines), policy=email_policy)
|
parent = email.message_from_bytes(b"\n".join(info.lines), policy=email_policy)
|
||||||
parent_id = (parent.get("Message-ID") or reply_to_msg_id).strip()
|
parent_id = (parent.get("Message-ID") or reply_to_msg_id).strip()
|
||||||
parent_subject = _decode(parent.get("Subject", ""))
|
parent_subject = _decode(parent.get("Subject", ""))
|
||||||
subject = parent_subject if re.match(r"(?i)^\s*(re|aw)\s*:", parent_subject) \
|
subject = parent_subject if re.match(r"(?i)^\s*(re|aw)\s*:", parent_subject) \
|
||||||
else f"Re: {parent_subject}"
|
else f"Re: {parent_subject}"
|
||||||
|
|
||||||
refs = [r for r in (parent.get("References") or "").split()
|
refs = [r for r in (parent.get("References") or "").split()
|
||||||
if r.startswith("<")]
|
if r.startswith("<")]
|
||||||
refs.append(parent_id)
|
refs.append(parent_id)
|
||||||
|
|
||||||
msg = EmailMessage()
|
msg = EmailMessage()
|
||||||
msg["From"] = formataddr((sender_name, sender_email))
|
msg["From"] = formataddr((sender_name, sender_email))
|
||||||
msg["Newsgroups"] = group
|
msg["Newsgroups"] = group
|
||||||
msg["Subject"] = subject
|
msg["Subject"] = subject
|
||||||
msg["Date"] = formatdate(localtime=True)
|
msg["Date"] = formatdate(localtime=True)
|
||||||
msg["Message-ID"] = make_msgid(domain=sender_email.split("@")[-1])
|
msg["Message-ID"] = make_msgid(domain=sender_email.split("@")[-1])
|
||||||
msg["In-Reply-To"] = parent_id
|
msg["In-Reply-To"] = parent_id
|
||||||
msg["References"] = " ".join(refs)
|
msg["References"] = " ".join(refs)
|
||||||
msg.set_content(body, charset="utf-8")
|
msg.set_content(body, charset="utf-8")
|
||||||
|
|
||||||
data = msg.as_bytes(policy=email_policy)
|
data = msg.as_bytes(policy=email_policy)
|
||||||
resp = s.post(data.split(b"\n"))
|
resp = s.post(data.split(b"\n"))
|
||||||
|
|
||||||
state = _load_state()
|
state = _load_state()
|
||||||
st = state.setdefault(_state_key(server, group),
|
st = state.setdefault(_state_key(server, group),
|
||||||
{"last_seen": None, "my_ids": []})
|
{"last_seen": None, "my_ids": []})
|
||||||
st.setdefault("my_ids", []).append(msg["Message-ID"])
|
st.setdefault("my_ids", []).append(msg["Message-ID"])
|
||||||
st["my_ids"] = st["my_ids"][-200:]
|
st["my_ids"] = st["my_ids"][-200:]
|
||||||
_save_state(state)
|
_save_state(state)
|
||||||
|
|
||||||
return {"status": "posted", "message_id": msg["Message-ID"],
|
return {"status": "posted", "message_id": msg["Message-ID"],
|
||||||
"subject": subject, "server_response": str(resp)}
|
"subject": subject, "server_response": str(resp)}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return {"error": f"Ошибка отправки: {e}"}
|
return {"error": f"Ошибка отправки: {e}"}
|
||||||
|
|
||||||
|
|
||||||
def list_groups(server: str, port: int = 119,
|
def list_groups(server: str = DEFAULT_SERVER,
|
||||||
user: str = "", password: str = "") -> list:
|
port: int = DEFAULT_PORT) -> list:
|
||||||
"""Диагностика: список групп на сервере."""
|
"""Диагностика: список групп на сервере."""
|
||||||
try:
|
try:
|
||||||
with _connect(server, port, user, password) as s:
|
with _connect(server, port) as s:
|
||||||
_, groups = s.list()
|
_, groups = s.list()
|
||||||
return [g for g in groups if g]
|
return [g for g in groups if g]
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return [f"Ошибка: {e}"]
|
return [f"Ошибка: {e}"]
|
||||||
|
|
||||||
|
|
||||||
# Безопасная централизованная регистрация инструментов через прокси-функцию
|
# Безопасная централизованная регистрация инструментов через прокси-функцию
|
||||||
scan_group = register_mcp_tool(scan_group)
|
scan_group = register_mcp_tool(scan_group)
|
||||||
read_thread = register_mcp_tool(read_thread)
|
read_thread = register_mcp_tool(read_thread)
|
||||||
post_reply = register_mcp_tool(post_reply)
|
post_reply = register_mcp_tool(post_reply)
|
||||||
list_groups = register_mcp_tool(list_groups)
|
list_groups = register_mcp_tool(list_groups)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
mcp.run(transport="stdio")
|
mcp.run(transport="stdio")
|
||||||
|
|
||||||
Reference in new issue
Block a user