实现了 Teams 机器人所需的全功能组件,包括: - 基础命令解析与帮助卡片生成 - 租户验证与访问控制 - 自定义 UA 与媒体工具 - 消息分块、批注处理与会话管理 - 防抖、缓存与配置路由能力 - 投票、配对、审计与运行时状态管理 - TTS 语音合成与卡片构建工具 - 群组管理与权限控制逻辑
356 lines
11 KiB
Python
356 lines
11 KiB
Python
"""Microsoft Teams 文件上传增强。
|
|
|
|
FileConsentCard 大文件交互流程 + SharePoint 上传策略 + SSRF 三层防护。
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import ipaddress
|
|
import re
|
|
import time
|
|
from pathlib import Path
|
|
from typing import Any
|
|
from urllib.parse import urlparse
|
|
|
|
from yuxi.utils.logging_config import logger
|
|
|
|
FILE_CONSENT_MAX_SIZE = 4 * 1024 * 1024
|
|
|
|
_SSRF_ALLOWED_DOMAINS = frozenset(
|
|
{
|
|
"graph.microsoft.com",
|
|
"graph.microsoft.us",
|
|
"login.microsoftonline.com",
|
|
"login.microsoftonline.us",
|
|
"api.botframework.com",
|
|
"api.botframework.us",
|
|
"smba.trafficmanager.net",
|
|
"sharepoint.com",
|
|
"sharepoint-df.com",
|
|
"onedrive.com",
|
|
"office.com",
|
|
"office.net",
|
|
}
|
|
)
|
|
|
|
_SSRF_DOMAIN_PARENT_PATTERN = re.compile(
|
|
r"^(.+\.)?(" + "|".join(map(re.escape, _SSRF_ALLOWED_DOMAINS)) + r")$",
|
|
re.IGNORECASE,
|
|
)
|
|
|
|
_SSRF_PRIVATE_RANGES = [
|
|
ipaddress.ip_network("10.0.0.0/8"),
|
|
ipaddress.ip_network("172.16.0.0/12"),
|
|
ipaddress.ip_network("192.168.0.0/16"),
|
|
ipaddress.ip_network("127.0.0.0/8"),
|
|
ipaddress.ip_network("169.254.0.0/16"),
|
|
ipaddress.ip_network("0.0.0.0/8"),
|
|
ipaddress.ip_network("fc00::/7"),
|
|
ipaddress.ip_network("::1/128"),
|
|
]
|
|
|
|
|
|
def _is_ssrf_safe_url(url: str) -> tuple[bool, str]:
|
|
"""三层 SSRF 防护:协议 → 域名白名单 → DNS 私有地址检测。
|
|
|
|
Returns:
|
|
(is_safe, reason) — safe 为 True 表示可安全请求。
|
|
"""
|
|
parsed = urlparse(url)
|
|
|
|
if parsed.scheme != "https":
|
|
return False, f"Protocol must be HTTPS, got '{parsed.scheme}'"
|
|
|
|
hostname = parsed.hostname or ""
|
|
if not hostname:
|
|
return False, "No hostname in URL"
|
|
|
|
if not _SSRF_DOMAIN_PARENT_PATTERN.match(hostname):
|
|
return False, f"Domain '{hostname}' not in SSRF allowlist"
|
|
|
|
try:
|
|
addr = ipaddress.ip_address(hostname)
|
|
for net in _SSRF_PRIVATE_RANGES:
|
|
if addr in net:
|
|
return False, f"IP '{hostname}' is in private range {net}"
|
|
except ValueError:
|
|
pass
|
|
|
|
return True, ""
|
|
|
|
|
|
def _validate_upload_url(url: str) -> bool:
|
|
safe, reason = _is_ssrf_safe_url(url)
|
|
if not safe:
|
|
logger.warning(f"MSTeams SSRF blocked: {reason} (url={url[:120]})")
|
|
return safe
|
|
|
|
|
|
def _validate_graph_url(url: str) -> bool:
|
|
parsed = urlparse(url)
|
|
hostname = (parsed.hostname or "").lower()
|
|
if hostname in ("graph.microsoft.com", "graph.microsoft.us"):
|
|
return True
|
|
logger.warning(f"MSTeams SSRF blocked: Graph URL host={hostname}")
|
|
return False
|
|
|
|
|
|
def needs_file_consent(file_size: int, chat_type: str = "direct") -> bool:
|
|
if chat_type == "direct":
|
|
return file_size > FILE_CONSENT_MAX_SIZE
|
|
return file_size > FILE_CONSENT_MAX_SIZE * 5
|
|
|
|
|
|
def choose_upload_strategy(
|
|
file_size: int,
|
|
chat_type: str,
|
|
sharepoint_site_id: str = "",
|
|
) -> str:
|
|
if chat_type == "direct" and file_size <= FILE_CONSENT_MAX_SIZE:
|
|
return "direct"
|
|
if sharepoint_site_id and chat_type in ("group", "channel"):
|
|
return "sharepoint"
|
|
if chat_type in ("group",) and file_size > FILE_CONSENT_MAX_SIZE:
|
|
return "onedrive"
|
|
return "direct"
|
|
|
|
|
|
def build_file_consent_card(
|
|
filename: str,
|
|
file_size: int,
|
|
description: str = "",
|
|
accept_context: dict[str, Any] | None = None,
|
|
decline_context: dict[str, Any] | None = None,
|
|
) -> dict[str, Any]:
|
|
size_mb = file_size / (1024 * 1024)
|
|
body_text = f"发送文件 **{filename}** ({size_mb:.1f} MB)"
|
|
if description:
|
|
body_text += f"\n\n{description}"
|
|
|
|
return {
|
|
"type": "AdaptiveCard",
|
|
"version": "1.5",
|
|
"body": [
|
|
{
|
|
"type": "TextBlock",
|
|
"size": "Large",
|
|
"weight": "Bolder",
|
|
"text": "文件发送确认",
|
|
},
|
|
{
|
|
"type": "TextBlock",
|
|
"text": body_text,
|
|
"wrap": True,
|
|
},
|
|
],
|
|
"actions": [
|
|
{
|
|
"type": "Action.Submit",
|
|
"title": "接受",
|
|
"style": "positive",
|
|
"data": {"action": "file_consent_accept", **(accept_context or {})},
|
|
},
|
|
{
|
|
"type": "Action.Submit",
|
|
"title": "拒绝",
|
|
"style": "destructive",
|
|
"data": {"action": "file_consent_decline", **(decline_context or {})},
|
|
},
|
|
],
|
|
}
|
|
|
|
|
|
async def upload_to_sharepoint(
|
|
token: str,
|
|
file_data: bytes,
|
|
filename: str,
|
|
site_id: str = "",
|
|
drive_id: str = "",
|
|
folder_path: str = "ForcePilot Uploads",
|
|
) -> dict[str, Any]:
|
|
import aiohttp
|
|
|
|
graph_url = "https://graph.microsoft.com/v1.0"
|
|
if not _validate_graph_url(graph_url):
|
|
return {"error": "SSRF blocked: invalid Graph URL"}
|
|
|
|
headers = {
|
|
"Authorization": f"Bearer {token}",
|
|
"Content-Type": "application/json",
|
|
}
|
|
|
|
if site_id and drive_id:
|
|
upload_path = f"/sites/{site_id}/drives/{drive_id}/root:/{folder_path}/{filename}:/createUploadSession"
|
|
else:
|
|
upload_path = f"/me/drive/root:/{folder_path}/{filename}:/createUploadSession"
|
|
|
|
upload_session_url = f"{graph_url}{upload_path}"
|
|
|
|
try:
|
|
async with aiohttp.ClientSession() as session:
|
|
async with session.post(
|
|
upload_session_url,
|
|
headers=headers,
|
|
json={"item": {"@microsoft.graph.conflictBehavior": "rename"}},
|
|
) as resp:
|
|
if resp.status not in (200, 201):
|
|
body = await resp.text()
|
|
logger.warning(f"SharePoint upload session failed: HTTP {resp.status} - {body[:200]}")
|
|
return {"error": f"HTTP {resp.status}", "detail": body[:500]}
|
|
upload_session = await resp.json()
|
|
|
|
upload_url = upload_session.get("uploadUrl", "")
|
|
if not upload_url:
|
|
return {"error": "No upload URL returned"}
|
|
|
|
if not _is_ssrf_safe_url(upload_url)[0]:
|
|
return {"error": "SSRF blocked: upload URL not allowed"}
|
|
|
|
total_size = len(file_data)
|
|
chunk_size = 4 * 1024 * 1024
|
|
offset = 0
|
|
|
|
while offset < total_size:
|
|
end = min(offset + chunk_size, total_size)
|
|
chunk = file_data[offset:end]
|
|
range_header = f"bytes {offset}-{end - 1}/{total_size}"
|
|
|
|
async with session.put(
|
|
upload_url,
|
|
data=chunk,
|
|
headers={"Content-Range": range_header, "Content-Length": str(len(chunk))},
|
|
) as resp:
|
|
if resp.status in (200, 201):
|
|
result = await resp.json()
|
|
web_url = result.get("webUrl", "")
|
|
return {"webUrl": web_url, "id": result.get("id", ""), "name": result.get("name", filename)}
|
|
if resp.status == 202:
|
|
offset = end
|
|
continue
|
|
body = await resp.text()
|
|
logger.warning(f"SharePoint chunk upload failed: HTTP {resp.status}")
|
|
return {"error": f"HTTP {resp.status}", "detail": body[:500]}
|
|
|
|
return {"error": "Upload incomplete"}
|
|
|
|
except Exception as e:
|
|
logger.error(f"SharePoint upload error: {e}")
|
|
return {"error": str(e)}
|
|
|
|
|
|
def create_shareable_link(token: str, item_id: str, link_type: str = "view") -> dict[str, Any]:
|
|
return {
|
|
"type": "share",
|
|
"item_id": item_id,
|
|
"link_type": link_type,
|
|
"create_link_endpoint": f"https://graph.microsoft.com/v1.0/me/drive/items/{item_id}/createLink",
|
|
}
|
|
|
|
|
|
def build_teams_file_info_card(
|
|
filename: str,
|
|
file_size: int,
|
|
content_url: str = "",
|
|
site_url: str = "",
|
|
) -> dict[str, Any]:
|
|
size_str = f"{file_size / (1024 * 1024):.1f} MB" if file_size >= 1024 * 1024 else f"{file_size / 1024:.1f} KB"
|
|
|
|
return {
|
|
"contentType": "application/vnd.microsoft.teams.file.info",
|
|
"content": {
|
|
"name": filename,
|
|
"size": file_size,
|
|
"siteUrl": site_url,
|
|
},
|
|
"metadata": {
|
|
"filename": filename,
|
|
"size_str": size_str,
|
|
"uploaded_at": "",
|
|
},
|
|
}
|
|
|
|
|
|
_PENDING_UPLOADS_DIRNAME = "msteams-pending-uploads"
|
|
|
|
|
|
class PendingUploadStore:
|
|
"""跨进程 FileConsentCard 上传状态持久化。
|
|
|
|
用于 Gateway 和 CLI 不同进程间的 FileConsentCard 回调共享。
|
|
"""
|
|
|
|
def __init__(self, storage_dir: str | None = None):
|
|
self._storage_dir = Path(storage_dir or str(Path.home() / ".yuxi" / _PENDING_UPLOADS_DIRNAME))
|
|
self._storage_dir.mkdir(parents=True, exist_ok=True)
|
|
|
|
def _file_path(self, upload_id: str) -> Path:
|
|
return self._storage_dir / f"{upload_id}.json"
|
|
|
|
def save_pending(
|
|
self,
|
|
upload_id: str,
|
|
filename: str,
|
|
file_data_b64: str,
|
|
conversation_id: str,
|
|
metadata: dict[str, Any] | None = None,
|
|
) -> None:
|
|
import json
|
|
|
|
entry = {
|
|
"upload_id": upload_id,
|
|
"filename": filename,
|
|
"file_data_b64": file_data_b64,
|
|
"conversation_id": conversation_id,
|
|
"created_at": time.time(),
|
|
"metadata": metadata or {},
|
|
}
|
|
try:
|
|
self._file_path(upload_id).write_text(
|
|
json.dumps(entry, ensure_ascii=False),
|
|
encoding="utf-8",
|
|
)
|
|
except OSError as e:
|
|
logger.error(f"MSTeams pending upload: failed to save {upload_id}: {e}")
|
|
|
|
def load_pending(self, upload_id: str) -> dict[str, Any] | None:
|
|
import json
|
|
|
|
fp = self._file_path(upload_id)
|
|
if not fp.exists():
|
|
return None
|
|
try:
|
|
return json.loads(fp.read_text(encoding="utf-8"))
|
|
except (json.JSONDecodeError, OSError) as e:
|
|
logger.warning(f"MSTeams pending upload: failed to load {upload_id}: {e}")
|
|
return None
|
|
|
|
def remove_pending(self, upload_id: str) -> None:
|
|
try:
|
|
fp = self._file_path(upload_id)
|
|
if fp.exists():
|
|
fp.unlink()
|
|
except OSError as e:
|
|
logger.warning(f"MSTeams pending upload: failed to remove {upload_id}: {e}")
|
|
|
|
def list_pending_ids(self) -> list[str]:
|
|
try:
|
|
files = list(self._storage_dir.glob("*.json"))
|
|
except OSError:
|
|
return []
|
|
return [f.stem for f in files]
|
|
|
|
def cleanup_expired(self, max_age_seconds: int = 3600) -> int:
|
|
now = time.time()
|
|
cleaned = 0
|
|
for fp in list(self._storage_dir.glob("*.json")):
|
|
try:
|
|
age = now - fp.stat().st_mtime
|
|
if age > max_age_seconds:
|
|
fp.unlink()
|
|
cleaned += 1
|
|
except OSError:
|
|
pass
|
|
if cleaned:
|
|
logger.info(f"MSTeams pending upload: cleaned {cleaned} expired entries")
|
|
return cleaned
|