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:
RRRosmontis
2026-11-11 00:25:30 +08:00
commit 34089d0042
106 changed files with 28028 additions and 0 deletions
+275
View File
@@ -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