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
+240
View File
@@ -0,0 +1,240 @@
"""Authenticated, deliberately limited hMailServer operations for the remote bot."""
import base64
import binascii
import hmac
import logging
import os
import re
from flask import Blueprint, jsonify, request
import com_backend as backend
log = logging.getLogger(__name__)
bp = Blueprint('admin_com', __name__, url_prefix='/v1/mail/api/admin/com')
def _positive_env_int(name, default):
try:
value = int(os.environ.get(name, default))
except (TypeError, ValueError):
return default
return value if value > 0 else default
MAX_REQUEST_BYTES = 1024 * 1024
ATTACHMENT_REQUEST_MAX_BYTES = _positive_env_int(
'MAIL_ATTACHMENT_REQUEST_MAX_BYTES', 40 * 1024 * 1024)
MAX_ATTACHMENT_COUNT = _positive_env_int('MAIL_ATTACHMENT_MAX_COUNT', 10)
MAX_ATTACHMENT_BYTES = _positive_env_int(
'MAIL_ATTACHMENT_MAX_FILE_BYTES', 20 * 1024 * 1024)
MAX_ATTACHMENT_TOTAL_BYTES = _positive_env_int(
'MAIL_ATTACHMENT_MAX_TOTAL_BYTES', 25 * 1024 * 1024)
@bp.before_request
def authenticate():
token = backend.CONFIG.get('token', '')
supplied = request.headers.get('Authorization', '')
if len(token) < 64 or not hmac.compare_digest(supplied.encode(), ('Bearer '+token).encode()):
return jsonify(error='unauthorized'), 401
is_attachment_send = request.endpoint == 'admin_com.send_with_attachments'
limit = ATTACHMENT_REQUEST_MAX_BYTES if is_attachment_send else MAX_REQUEST_BYTES
# 新端点拒绝无 Content-Length 的流式请求,避免绕过入口大小限制;
# 其他端点保留原有 1 MiB 行为和响应格式。
if is_attachment_send and request.content_length is None:
return jsonify(error='content length required'), 411
if request.content_length and request.content_length > limit:
return jsonify(error='request too large'), 413
@bp.after_request
def no_cache(response):
response.headers['Cache-Control'] = 'no-store'
return response
def address(value, local=True):
if not isinstance(value, str) or not re.fullmatch(r'[^\s<>@,;]+@[^\s<>@,;]+', value):
raise ValueError('invalid address')
value = value.lower()
if local and value.rsplit('@', 1)[1] not in backend.CONFIG['allowed_domains']:
raise ValueError('unsupported domain')
return value
def integer(value, low, high):
if isinstance(value, bool): raise ValueError('invalid integer')
value = int(value)
if value < low or value > high: raise ValueError('integer out of range')
return value
def attachment_filename(value):
"""只接受 MIME 显示文件名,绝不接受客户端提供的服务器路径。"""
if not isinstance(value, str) or not value or len(value) > 255:
raise ValueError('invalid attachment filename')
if value != value.strip() or value in ('.', '..'):
raise ValueError('invalid attachment filename')
if len(value.encode('utf-8')) > 1024:
raise ValueError('invalid attachment filename')
if any(ord(ch) < 32 or ord(ch) == 127 or ch in '<>:"/\\|?*' for ch in value):
raise ValueError('invalid attachment filename')
return value
def attachment_content_type(value):
# 仅 type/subtype,不含参数;同时排除 CR/LF 与其他头部注入字符。
if not isinstance(value, str) or len(value) > 127:
raise ValueError('invalid attachment MIME type')
if not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9!#$&^_.+-]*/"
r"[A-Za-z0-9][A-Za-z0-9!#$&^_.+-]*", value):
raise ValueError('invalid attachment MIME type')
return value.lower()
def incoming_attachments(value):
if not isinstance(value, list) or not 1 <= len(value) <= MAX_ATTACHMENT_COUNT:
raise ValueError('invalid attachment count')
result, total = [], 0
max_encoded_size = 4 * ((MAX_ATTACHMENT_BYTES + 2) // 3)
for item in value:
# 白名单字段可防止把 path / filename_on_server 等字段带入后端。
if not isinstance(item, dict) or set(item) != {'filename', 'content_type', 'data_base64'}:
raise ValueError('invalid attachment')
filename = attachment_filename(item['filename'])
content_type = attachment_content_type(item['content_type'])
encoded = item['data_base64']
if not isinstance(encoded, str) or len(encoded) > max_encoded_size:
raise ValueError('attachment too large')
try:
content = base64.b64decode(encoded.encode('ascii'), validate=True)
except (UnicodeError, binascii.Error, ValueError):
raise ValueError('invalid attachment base64')
if len(content) > MAX_ATTACHMENT_BYTES:
raise ValueError('attachment too large')
total += len(content)
if total > MAX_ATTACHMENT_TOTAL_BYTES:
raise ValueError('attachments too large')
result.append({'filename': filename, 'content_type': content_type,
'content': content})
return result, total
def item(m):
return {'id': int(m.ID), 'subject': (m.Subject or '').strip() or '(无主题)',
'from': (m.From or '').strip(), 'from_address': (m.FromAddress or '').strip(),
'ts': backend._message_ts(m)}
@bp.route('/status', methods=['GET'])
def status():
with backend._ComSession() as app:
return jsonify(ok=True, version=app.Version)
@bp.route('/inbox', methods=['POST'])
def inbox():
data = request.get_json()
addr = address(data.get('address'))
limit = integer(data.get('limit', 10), 1, 100)
window = data.get('window_seconds')
if window is not None: window = integer(window, 0, 315360000)
with backend._ComSession() as app:
result = backend._list_inbox_messages(app, addr, window, limit)
return jsonify(items=result)
@bp.route('/message', methods=['POST'])
def message():
data = request.get_json()
addresses = data.get('addresses')
if not isinstance(addresses, list) or not 1 <= len(addresses) <= 30:
raise ValueError('invalid address list')
addresses = [address(a) for a in addresses]
mid = integer(data.get('id'), 1, 2**63-1)
with backend._ComSession() as app:
m = backend._find_message_by_id(app, addresses, mid)
if m is None: return jsonify(message=None)
images, attachments = backend._analyze_message(m)
result = dict(ID=int(m.ID), Subject=m.Subject or '', From=m.From or '',
FromAddress=m.FromAddress or '', Date=str(m.Date or ''),
Body=m.Body or '', HTMLBody=m.HTMLBody or '',
image_count=images, attachment_names=attachments)
return jsonify(message=result)
@bp.route('/changes', methods=['POST'])
def changes():
data = request.get_json()
addr = address(data.get('address'))
after = data.get('after')
if after is not None: after = integer(after, 0, 2**63-1)
limit = integer(data.get('limit', 10), 1, 100)
with backend._ComSession() as app:
msgs = backend._get_account(app, addr).IMAPFolders.ItemByName('INBOX').Messages
found, max_id = [], 0
for idx in range(msgs.Count):
m = msgs[idx]
mid = int(m.ID)
max_id = max(max_id, mid)
if after is not None and mid > after: found.append(item(m))
found.sort(key=lambda v: v['id'])
return jsonify(max_id=max_id, items=found[:limit])
def outgoing(data=None):
if data is None:
data = request.get_json()
sender, recipient = address(data.get('from_address')), address(data.get('to_address'), local=False)
subject, body = data.get('subject'), data.get('body_text')
if not isinstance(subject, str) or len(subject) > 2000 or '\r' in subject or '\n' in subject:
raise ValueError('invalid subject')
if not isinstance(body, str) or len(body) > 200000: raise ValueError('invalid body')
return sender, recipient, subject, body
@bp.route('/send', methods=['POST'])
def send():
args = outgoing()
with backend._ComSession() as app:
backend._get_account(app, args[0])
backend._send_mail(app, *args)
return jsonify(accepted=True)
@bp.route('/send_with_attachments', methods=['POST'])
def send_with_attachments():
data = request.get_json()
if not isinstance(data, dict):
raise ValueError('invalid request')
args = outgoing(data)
if not args[2].strip():
raise ValueError('invalid subject')
attachments, total = incoming_attachments(data.get('attachments'))
with backend._ComSession() as app:
backend._get_account(app, args[0])
sent_saved = backend._send_mail_with_attachments(app, *args, attachments)
return jsonify(ok=True, accepted=True, sent_saved=sent_saved,
attachment_names=[a['filename'] for a in attachments],
size=total)
@bp.route('/sent', methods=['POST'])
def sent():
args = outgoing()
with backend._ComSession() as app:
backend._save_to_sent(app, *args)
return jsonify(ok=True)
@bp.errorhandler(Exception)
def error(exc):
from werkzeug.exceptions import HTTPException
if isinstance(exc, HTTPException): return jsonify(error=exc.name), exc.code
if isinstance(exc, (ValueError, TypeError, AttributeError)):
return jsonify(error='invalid request'), 400
log.exception('COM operation failed')
return jsonify(error='mail backend unavailable'), 502
def install(app):
app.register_blueprint(bp)