ForcePilot/backend/package/yuxi/channel/extensions/msteams/polls.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

300 lines
8.7 KiB
Python

from __future__ import annotations
import json
import logging
import os
import secrets
from dataclasses import dataclass, field
from datetime import UTC, datetime, timedelta
from .adaptive_card import build_attachment, build_poll_card
from .sdk import BotFrameworkAdapter
from .types import StoredConversationReference
logger = logging.getLogger(__name__)
MAX_POLLS = 1000
POLL_TTL_DAYS = 30
@dataclass
class Poll:
poll_id: str
question: str
options: list[str]
is_multi_select: bool = False
conversation_id: str = ""
message_id: str = ""
service_url: str = ""
tenant_id: str | None = None
created_at: str = ""
votes: dict[str, list[str]] = field(default_factory=dict)
closed: bool = False
@property
def results(self) -> dict:
counts = {opt: 0 for opt in self.options}
for voter, selected in self.votes.items():
for opt in selected:
if opt in counts:
counts[opt] += 1
return {
"question": self.question,
"options": self.options,
"is_multi_select": self.is_multi_select,
"total_voters": len(self.votes),
"votes": counts,
"closed": self.closed,
}
def to_dict(self) -> dict:
return {
"poll_id": self.poll_id,
"question": self.question,
"options": self.options,
"is_multi_select": self.is_multi_select,
"conversation_id": self.conversation_id,
"message_id": self.message_id,
"service_url": self.service_url,
"tenant_id": self.tenant_id,
"created_at": self.created_at,
"votes": self.votes,
"closed": self.closed,
}
@classmethod
def from_dict(cls, data: dict) -> Poll:
return cls(
poll_id=data.get("poll_id", ""),
question=data.get("question", ""),
options=data.get("options", []),
is_multi_select=data.get("is_multi_select", False),
conversation_id=data.get("conversation_id", ""),
message_id=data.get("message_id", ""),
service_url=data.get("service_url", ""),
tenant_id=data.get("tenant_id"),
created_at=data.get("created_at", ""),
votes=data.get("votes", {}),
closed=data.get("closed", False),
)
class PollStore:
def __init__(self, file_path: str):
self._file_path = file_path
self._polls: dict[str, Poll] = {}
def load(self) -> None:
try:
if not os.path.exists(self._file_path):
return
with open(self._file_path, encoding="utf-8") as f:
data = json.load(f)
if not isinstance(data, dict):
return
cutoff = (datetime.now(UTC) - timedelta(days=POLL_TTL_DAYS)).isoformat()
loaded = 0
for poll_id, entry in data.items():
created = entry.get("created_at", "")
if created and created < cutoff:
continue
self._polls[poll_id] = Poll.from_dict(entry)
loaded += 1
logger.info("Loaded %d polls from %s", loaded, self._file_path)
except Exception:
logger.exception("Failed to load poll store from %s", self._file_path)
def save(self) -> None:
try:
os.makedirs(os.path.dirname(self._file_path) or ".", exist_ok=True)
data = {}
for poll_id, poll in self._polls.items():
data[poll_id] = poll.to_dict()
with open(self._file_path, "w", encoding="utf-8") as f:
json.dump(data, f, indent=2, ensure_ascii=False)
except Exception:
logger.exception("Failed to save poll store to %s", self._file_path)
def create(
self,
question: str,
options: list[str],
*,
is_multi_select: bool = False,
conversation_id: str = "",
message_id: str = "",
service_url: str = "",
tenant_id: str | None = None,
) -> Poll:
poll_id = secrets.token_hex(12)
now = datetime.now(UTC).isoformat()
poll = Poll(
poll_id=poll_id,
question=question,
options=options,
is_multi_select=is_multi_select,
conversation_id=conversation_id,
message_id=message_id,
service_url=service_url,
tenant_id=tenant_id,
created_at=now,
)
self._polls[poll_id] = poll
self._evict_if_needed()
self.save()
return poll
def get(self, poll_id: str) -> Poll | None:
poll = self._polls.get(poll_id)
if poll and poll.closed:
return None
if poll:
created = datetime.fromisoformat(poll.created_at)
if datetime.now(UTC) - created > timedelta(days=POLL_TTL_DAYS):
self._polls.pop(poll_id, None)
self.save()
return None
return poll
def vote(
self,
poll_id: str,
voter_id: str,
selected: list[str],
) -> Poll | None:
poll = self.get(poll_id)
if not poll:
return None
valid = [opt for opt in selected if opt in poll.options]
if not valid:
return None
if poll.is_multi_select:
poll.votes[voter_id] = valid
else:
poll.votes[voter_id] = valid[:1]
self.save()
return poll
def close(self, poll_id: str) -> bool:
poll = self._polls.get(poll_id)
if not poll:
return False
poll.closed = True
self.save()
return True
def remove(self, poll_id: str) -> bool:
if poll_id in self._polls:
self._polls.pop(poll_id, None)
self.save()
return True
return False
def _evict_if_needed(self) -> None:
if len(self._polls) <= MAX_POLLS:
return
sorted_polls = sorted(
self._polls.items(),
key=lambda x: x[1].created_at or "",
)
to_remove = len(self._polls) - MAX_POLLS
for poll_id, _ in sorted_polls[:to_remove]:
self._polls.pop(poll_id, None)
logger.info("Evicted %d oldest polls (limit=%d)", to_remove, MAX_POLLS)
def list_all(self) -> list[Poll]:
return list(self._polls.values())
def __len__(self) -> int:
return len(self._polls)
async def send_poll(
adapter: BotFrameworkAdapter,
ref: StoredConversationReference,
question: str,
options: list[str],
poll_store: PollStore,
*,
is_multi_select: bool = False,
reply_to_id: str | None = None,
) -> Poll:
card = build_poll_card(question, options, is_multi_select=is_multi_select)
attachment = build_attachment(card)
activity: dict = {
"type": "message",
"attachments": [attachment],
}
if reply_to_id:
activity["replyToId"] = reply_to_id
if ref.tenant_id:
activity.setdefault("channelData", {})
activity["channelData"]["tenant"] = {"id": ref.tenant_id}
result = await adapter.send_to_conversation(ref.service_url, ref.conversation_id, activity)
message_id = result.get("id", "")
poll = poll_store.create(
question=question,
options=options,
is_multi_select=is_multi_select,
conversation_id=ref.conversation_id,
message_id=message_id,
service_url=ref.service_url,
tenant_id=ref.tenant_id,
)
return poll
async def handle_poll_vote(
adapter: BotFrameworkAdapter,
poll_store: PollStore,
vote_data: dict,
*,
sender_id: str = "",
) -> dict:
poll_id = vote_data.get("poll_id", "")
selected = vote_data.get("selected", [])
if isinstance(selected, str):
selected = [selected]
if not poll_id or not selected:
return {"success": False, "error": "Missing poll_id or selected options"}
poll = poll_store.vote(poll_id, sender_id, selected)
if not poll:
return {"success": False, "error": "Poll not found or expired"}
results = poll.results
return {
"success": True,
"result": {
"poll_id": poll_id,
"question": poll.question,
"results": results,
},
}
def build_poll_results_text(poll: Poll) -> str:
results = poll.results
total = results["total_voters"]
lines = [f"**📊 {poll.question}**", f"总票数: {total}", ""]
for opt in poll.options:
count = results["votes"].get(opt, 0)
bar_len = max(1, int(count / max(total, 1) * 10))
bar = "" * bar_len + "" * (10 - bar_len)
lines.append(f"{bar} {opt} ({count})")
return "\n".join(lines)