ForcePilot/backend/package/yuxi/channel/extensions/msteams/files.py
Kris 94444ced96 feat(channel): 添加 Microsoft Teams 渠道扩展
新增 Microsoft Teams 渠道扩展,支持在 Yuxi 平台中集成 Microsoft Teams 协作平台。

包含以下功能模块:
- sdk: Bot Framework SDK 封装
- config: 渠道配置管理
- gateway: SSE/WebSocket 网关接入
- webhook: Webhook 事件处理
- outbound: 外发消息管理
- streaming: 流式消息处理
- pairing: 用户配对与绑定
- security: 安全校验
- auth: JWT 认证
- jwks: JWKS 密钥管理
- dedupe: 消息去重
- monitor: 渠道状态监控
- status: 会话状态管理
- session: 会话管理
- state: 状态管理
- runtime: 运行时管理
- actions: 动作处理
- adaptive_card: 自适应卡片
- task_modules: 任务模块
- message_extension: 消息扩展
- proactive: Proactive Messaging
- graph: Microsoft Graph API 集成
- graph_teams: Teams 操作
- graph_members: 成员管理
- graph_messages: 消息获取
- graph_thread: 线程管理
- graph_users: 用户管理
- graph_upload: 文件上传
- files: 文件处理
- file_consent: 文件授权
- conversations: 会话存储
- mentions: @提及处理
- threading: 线程管理
- reactions: 表情反应
- polls: 投票功能
- meetings: 会议集成
- feedback: 反馈处理
- sso: 单点登录
- deep_links: 深层链接
- incoming_webhook: 入站 Webhook
- localization: 本地化
- user_agent: 用户代理
- sent_message_cache: 消息缓存
- types: 类型定义
2026-05-21 11:28:42 +08:00

178 lines
4.8 KiB
Python

from __future__ import annotations
import ipaddress
import logging
import re
from enum import StrEnum
logger = logging.getLogger(__name__)
SSRF_BLOCKED_DOMAINS: tuple[str, ...] = (
"169.254.",
"metadata.google.internal",
"metadata.tencentyun.com",
"100.64.",
"0.0.0.0",
)
SSRF_BLOCKED_NETS: tuple[ipaddress.IPv4Network | ipaddress.IPv6Network, ...] = (
ipaddress.IPv4Network("127.0.0.0/8"),
ipaddress.IPv4Network("10.0.0.0/8"),
ipaddress.IPv4Network("172.16.0.0/12"),
ipaddress.IPv4Network("192.168.0.0/16"),
ipaddress.IPv4Network("169.254.0.0/16"),
ipaddress.IPv4Network("100.64.0.0/10"),
ipaddress.IPv4Network("0.0.0.0/8"),
ipaddress.IPv6Network("::1/128"),
ipaddress.IPv6Network("fe80::/10"),
)
MAX_DOWNLOAD_SIZE = 100 * 1024 * 1024
class UploadRoute(StrEnum):
ATTACHMENT_URL = "attachment_url"
CONNECTOR = "connector"
ONEDRIVE = "onedrive"
SHAREPOINT = "sharepoint"
M365_NATIVE = "m365_native"
def is_url_microsoft_safe(url: str) -> bool:
try:
host = _extract_host(url)
if not host:
return False
host_lower = host.lower()
microsoft_domains = (
"graph.microsoft.com",
"graph.microsoft.us",
"api.teams.microsoft.com",
"login.microsoftonline.com",
"login.microsoftonline.us",
"smba.trafficmanager.net",
"sharepoint.com",
"sharepoint.cn",
"onedrive.com",
"office.com",
"office365.com",
"microsoft.com",
"microsoftonline.com",
"azure.com",
"azure.cn",
)
if host_lower == "localhost":
return False
if host_lower in ("127.0.0.1", "::1", "0.0.0.0"):
return False
for domain in microsoft_domains:
if host_lower == domain or host_lower.endswith(f".{domain}"):
return True
return False
except Exception:
return False
def _validate_url_safety(url: str) -> bool:
try:
host = _extract_host(url)
if not host:
return False
host_lower = host.lower()
if host_lower == "localhost":
logger.warning("SSRF blocked: localhost")
return False
if host_lower in ("127.0.0.1", "::1", "0.0.0.0"):
logger.warning("SSRF blocked: loopback %s", host_lower)
return False
try:
ip = ipaddress.ip_address(host)
for net in SSRF_BLOCKED_NETS:
if ip in net:
logger.warning("SSRF blocked: private IP %s", host)
return False
return True
except ValueError:
pass
for domain in SSRF_BLOCKED_DOMAINS:
if domain in host_lower:
logger.warning("SSRF blocked: %s contains %s", host_lower, domain)
return False
try:
ip = ipaddress.ip_address(host)
for net in SSRF_BLOCKED_NETS:
if ip in net:
logger.warning("SSRF blocked: resolved IP %s is private", ip)
return False
except ValueError:
pass
return True
except Exception:
return False
def resolve_upload_route(
file_size: int,
*,
url: str = "",
is_team_channel: bool = False,
) -> UploadRoute:
if url and is_url_microsoft_safe(url):
return UploadRoute.M365_NATIVE
if file_size < 4 * 1024 * 1024:
return UploadRoute.ATTACHMENT_URL
if file_size < 50 * 1024 * 1024:
return UploadRoute.CONNECTOR
if is_team_channel:
return UploadRoute.SHAREPOINT
return UploadRoute.ONEDRIVE
def _extract_host(url: str) -> str:
url = url.strip()
if "://" not in url:
url = f"https://{url}"
idx = url.index("://") + 3
host = url[idx:]
host = re.split(r"[/?#:@]", host)[0]
return host.strip()
async def download_public_file(url: str) -> bytes | None:
if not _validate_url_safety(url):
return None
try:
import httpx
async with httpx.AsyncClient(timeout=60.0, follow_redirects=True) as client:
resp = await client.get(url)
resp.raise_for_status()
content_length = int(resp.headers.get("content-length", "0"))
if content_length > MAX_DOWNLOAD_SIZE:
logger.warning("File too large: %d bytes from %s", content_length, url)
return None
content = await resp.aread()
if len(content) > MAX_DOWNLOAD_SIZE:
logger.warning("File too large: %d bytes from %s", len(content), url)
return None
return content
except Exception as e:
logger.warning("Failed to download file from %s: %s", url, e)
return None