新增 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: 类型定义
300 lines
8.7 KiB
Python
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)
|