Initial import of Shirobana bot backend
Include utility modules, shared channels, prompts, read-only skills, wardrobe memory and regression tests. Externalize legacy credentials and exclude private runtime state.
This commit is contained in:
@@ -0,0 +1,275 @@
|
||||
# COM helpers preserved from the current Windows utility bot.
|
||||
import os, json, time, email
|
||||
import smtplib
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from email import encoders
|
||||
from email.header import Header
|
||||
from email.mime.base import MIMEBase
|
||||
from email.mime.multipart import MIMEMultipart
|
||||
from email.mime.text import MIMEText
|
||||
from email.utils import formatdate, make_msgid, parsedate_to_datetime
|
||||
CONFIG_PATH = Path(os.environ.get("MAIL_COM_CONFIG", str(Path(__file__).with_name("com-private.json"))))
|
||||
CONFIG = json.loads(CONFIG_PATH.read_text(encoding="utf-8"))
|
||||
HMAIL_ADMIN_PASSWORD = CONFIG["hmail_admin_password"]
|
||||
LIST_MAX = 10
|
||||
|
||||
|
||||
class _ComSession:
|
||||
"""线程内 COM 会话:CoInitialize + Dispatch + Authenticate,退出时 CoUninitialize。"""
|
||||
|
||||
def __enter__(self):
|
||||
import pythoncom
|
||||
import win32com.client
|
||||
self._pycom = pythoncom
|
||||
pythoncom.CoInitialize()
|
||||
try:
|
||||
app = win32com.client.Dispatch("hMailServer.Application")
|
||||
except Exception:
|
||||
pythoncom.CoUninitialize()
|
||||
raise
|
||||
if not app.Authenticate("Administrator", HMAIL_ADMIN_PASSWORD):
|
||||
pythoncom.CoUninitialize()
|
||||
raise RuntimeError("hMailServer 管理员认证失败")
|
||||
self.app = app
|
||||
return app
|
||||
|
||||
def __exit__(self, *exc):
|
||||
self._pycom.CoUninitialize()
|
||||
return False
|
||||
|
||||
def _get_account(app, address):
|
||||
domain_name = address.split("@", 1)[1]
|
||||
domain = app.Domains.ItemByName(domain_name)
|
||||
return domain.Accounts.ItemByAddress(address)
|
||||
|
||||
def _message_ts(m):
|
||||
for attr in ("InternalDate", "Date"):
|
||||
try:
|
||||
v = getattr(m, attr)
|
||||
except Exception:
|
||||
continue
|
||||
if v is None:
|
||||
continue
|
||||
if isinstance(v, str):
|
||||
try:
|
||||
return parsedate_to_datetime(v).timestamp()
|
||||
except Exception:
|
||||
continue
|
||||
else: # datetime-like
|
||||
try:
|
||||
return v.timestamp()
|
||||
except Exception:
|
||||
continue
|
||||
return 0.0
|
||||
|
||||
def _decode_mime(s):
|
||||
if not s:
|
||||
return ""
|
||||
try:
|
||||
from email.header import decode_header
|
||||
parts = decode_header(s)
|
||||
out = []
|
||||
for p, cs in parts:
|
||||
if isinstance(p, bytes):
|
||||
out.append(p.decode(cs or "utf-8", "replace"))
|
||||
else:
|
||||
out.append(p)
|
||||
return "".join(out)
|
||||
except Exception:
|
||||
return s
|
||||
|
||||
def _analyze_message(m):
|
||||
"""解析 .eml,返回 (image_count, attachment_names)。"""
|
||||
image_count = 0
|
||||
attachment_names = []
|
||||
try:
|
||||
fn = m.Filename
|
||||
if not fn or not os.path.exists(fn):
|
||||
return image_count, attachment_names
|
||||
with open(fn, "rb") as f:
|
||||
msg = email.message_from_bytes(f.read())
|
||||
for part in msg.walk():
|
||||
if part.is_multipart():
|
||||
continue
|
||||
ctype = (part.get_content_type() or "").lower()
|
||||
disp = (part.get("Content-Disposition") or "").lower()
|
||||
cid = part.get("Content-ID")
|
||||
filename = part.get_filename()
|
||||
if ctype.startswith("image/") or cid:
|
||||
image_count += 1
|
||||
continue
|
||||
if "attachment" in disp or filename:
|
||||
name = _decode_mime(filename) if filename else ""
|
||||
attachment_names.append(name or "(未命名附件)")
|
||||
except Exception:
|
||||
pass
|
||||
return image_count, attachment_names
|
||||
|
||||
def _list_inbox_messages(app, address, window_seconds=None, limit=LIST_MAX):
|
||||
account = _get_account(app, address)
|
||||
inbox = account.IMAPFolders.ItemByName("INBOX")
|
||||
msgs = inbox.Messages
|
||||
items = []
|
||||
now = time.time()
|
||||
for i in range(msgs.Count):
|
||||
m = msgs[i]
|
||||
ts = _message_ts(m)
|
||||
if window_seconds is not None and (now - ts) > window_seconds:
|
||||
continue
|
||||
items.append({
|
||||
"id": int(m.ID),
|
||||
"subject": (m.Subject or "").strip() or "(无主题)",
|
||||
"from": (m.From or "").strip(),
|
||||
"from_address": (m.FromAddress or "").strip(),
|
||||
"ts": ts,
|
||||
})
|
||||
items.sort(key=lambda x: x["ts"], reverse=True)
|
||||
return items[:limit]
|
||||
|
||||
def _find_message_by_id(app, addresses, msg_id):
|
||||
for addr in addresses:
|
||||
try:
|
||||
account = _get_account(app, addr)
|
||||
inbox = account.IMAPFolders.ItemByName("INBOX")
|
||||
return inbox.Messages.ItemByDBID(int(msg_id))
|
||||
except Exception:
|
||||
continue
|
||||
return None
|
||||
|
||||
def _send_mail(app, from_address, to_address, subject, body_text):
|
||||
import win32com.client
|
||||
msg = win32com.client.Dispatch("hMailServer.Message")
|
||||
msg.From = f"{from_address.split('@', 1)[0]} <{from_address}>"
|
||||
msg.FromAddress = from_address
|
||||
msg.Subject = subject
|
||||
msg.Body = body_text
|
||||
msg.AddRecipient("", to_address)
|
||||
msg.Save()
|
||||
|
||||
|
||||
def _build_attachment_mime(from_address, to_address, subject, body_text, attachments):
|
||||
"""构造与 mailWeb.py 相同风格的 multipart/mixed UTF-8 邮件。
|
||||
|
||||
attachments 中只含已经解码的 bytes,绝不含服务器文件路径。
|
||||
"""
|
||||
msg = MIMEMultipart("mixed")
|
||||
msg["From"] = f"{from_address.split('@', 1)[0]} <{from_address}>"
|
||||
msg["To"] = to_address
|
||||
msg["Subject"] = subject
|
||||
msg["Date"] = formatdate(localtime=True)
|
||||
msg["Message-ID"] = make_msgid()
|
||||
msg.attach(MIMEText(body_text, "plain", "utf-8"))
|
||||
|
||||
names = []
|
||||
for attachment in attachments:
|
||||
filename = attachment["filename"]
|
||||
content_type = attachment["content_type"]
|
||||
maintype, subtype = content_type.split("/", 1)
|
||||
part = MIMEBase(maintype, subtype)
|
||||
part.set_payload(attachment["content"])
|
||||
encoders.encode_base64(part)
|
||||
# email 包会对中文文件名生成 RFC 2231 编码参数。
|
||||
part.add_header("Content-Disposition", "attachment", filename=filename)
|
||||
msg.attach(part)
|
||||
names.append(filename)
|
||||
if names:
|
||||
# 保留 mailWeb.py 的兼容元数据;真正的附件仍以标准 MIME part 为准。
|
||||
msg["X-Attachments"] = ",".join(str(Header(name, "utf-8")) for name in names)
|
||||
return msg
|
||||
|
||||
|
||||
def _deliver_mime(from_address, to_address, mime_bytes):
|
||||
"""经本机 hMailServer SMTP 投递原始 MIME,避免猜测 COM 原始 MIME 属性。
|
||||
|
||||
Windows 实机验证点:hMailServer 的 127.0.0.1 IP range 必须允许本机
|
||||
发件账号向目标地址投递;如生产使用 SSL/STARTTLS/认证,在 com-private.json
|
||||
配置 smtp_ssl / smtp_starttls / smtp_username / smtp_password。
|
||||
"""
|
||||
host = CONFIG.get("smtp_host", "127.0.0.1")
|
||||
port = int(CONFIG.get("smtp_port", 465 if CONFIG.get("smtp_ssl") else 25))
|
||||
timeout = float(CONFIG.get("smtp_timeout", 30))
|
||||
smtp_class = smtplib.SMTP_SSL if CONFIG.get("smtp_ssl") else smtplib.SMTP
|
||||
with smtp_class(host, port, timeout=timeout) as smtp:
|
||||
if CONFIG.get("smtp_starttls"):
|
||||
smtp.starttls()
|
||||
username = CONFIG.get("smtp_username")
|
||||
if username:
|
||||
smtp.login(username, CONFIG.get("smtp_password", ""))
|
||||
refused = smtp.sendmail(from_address, [to_address], mime_bytes)
|
||||
if refused:
|
||||
raise RuntimeError("hMailServer SMTP 未接受全部收件人")
|
||||
|
||||
|
||||
def _save_attachment_copy_to_sent(app, from_address, to_address, subject,
|
||||
body_text, attachments):
|
||||
"""best-effort 保存同一逻辑邮件(正文及附件)到 Sent,不再次投递。
|
||||
|
||||
hMailServer 5.6 COM Message 没有公开的原始 MIME 写入接口;这里只使用
|
||||
文档化的 Messages.Add / Attachments.Add。附件先落在后端自建的临时目录,
|
||||
文件名已经过端点校验,客户端不能指定任何服务器路径。COM 生成的 Sent
|
||||
MIME 可能与线上 MIME 的边界、日期等字节不同。
|
||||
|
||||
Windows 实机验证点:确认 Attachments.Add 能读取服务账号临时目录,且
|
||||
中文附件名在 Sent 和 WebMail 中显示正确。
|
||||
"""
|
||||
try:
|
||||
account = _get_account(app, from_address)
|
||||
try:
|
||||
sent = account.IMAPFolders.ItemByName("Sent")
|
||||
except Exception:
|
||||
return False
|
||||
with tempfile.TemporaryDirectory(prefix="mail-attachments-") as tmp:
|
||||
m = sent.Messages.Add()
|
||||
m.From = f"{from_address.split('@', 1)[0]} <{from_address}>"
|
||||
m.FromAddress = from_address
|
||||
m.Subject = subject
|
||||
m.Body = body_text
|
||||
m.AddRecipient("", to_address)
|
||||
for index, attachment in enumerate(attachments):
|
||||
entry_dir = os.path.join(tmp, str(index))
|
||||
os.mkdir(entry_dir)
|
||||
disk_path = os.path.join(entry_dir, attachment["filename"])
|
||||
with open(disk_path, "wb") as fh:
|
||||
fh.write(attachment["content"])
|
||||
m.Attachments.Add(disk_path)
|
||||
m.Save()
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def _send_mail_with_attachments(app, from_address, to_address, subject,
|
||||
body_text, attachments):
|
||||
"""投递附件邮件并 best-effort 保存 Sent;无附件沿用旧 COM 路径。"""
|
||||
if not attachments:
|
||||
_send_mail(app, from_address, to_address, subject, body_text)
|
||||
_save_to_sent(app, from_address, to_address, subject, body_text)
|
||||
return None
|
||||
msg = _build_attachment_mime(from_address, to_address, subject,
|
||||
body_text, attachments)
|
||||
_deliver_mime(from_address, to_address, msg.as_bytes())
|
||||
return _save_attachment_copy_to_sent(
|
||||
app, from_address, to_address, subject, body_text, attachments)
|
||||
|
||||
|
||||
def _save_to_sent(app, from_address, to_address, subject, body_text):
|
||||
"""把发件副本写入发件人账号的 Sent 文件夹(best-effort,不投递)。"""
|
||||
try:
|
||||
account = _get_account(app, from_address)
|
||||
try:
|
||||
sent = account.IMAPFolders.ItemByName("Sent")
|
||||
except Exception:
|
||||
return
|
||||
m = sent.Messages.Add()
|
||||
m.From = f"{from_address.split('@', 1)[0]} <{from_address}>"
|
||||
m.FromAddress = from_address
|
||||
m.Subject = subject
|
||||
m.Body = body_text
|
||||
try:
|
||||
m.AddRecipient("", to_address)
|
||||
except Exception:
|
||||
pass
|
||||
m.Save()
|
||||
except Exception:
|
||||
pass
|
||||
Reference in New Issue
Block a user