ForcePilot/backend/package/yuxi/channels/adapters/feishu/directory.py
Kris 69b7139863 refactor(feishu): 清理冗余代码并补全飞书工具集实现
1.  移除多个文件中的空行、冗余导入
2.  修复文件末尾缺少换行符的问题
3.  新增并补全飞书多类工具API实现:
    - 多维表格:更新、删除记录,列出视图
    - 文档:更新、追加、删除块
    - 云文档:上传、下载文件
    - 群组:创建、添加成员、更新信息、创建公告
    - 目录:重构用户部门缓存逻辑
4.  优化消息发送、回复、转发等API的错误处理和逻辑
5.  新增消息列表查询、已读状态查询等功能
2026-05-13 16:07:59 +08:00

185 lines
6.0 KiB
Python

from __future__ import annotations
import asyncio
import time
from typing import Any
PAGE_SIZE_DEFAULT = 100
PAGE_SIZE_MAX = 200
CACHE_TTL_USERS_S = 300
CACHE_TTL_DEPARTMENTS_S = 600
class FeishuDirectoryClient:
def __init__(self, api_client: Any, page_size: int = PAGE_SIZE_DEFAULT):
self._api = api_client
self._page_size = min(page_size, PAGE_SIZE_MAX)
self._lock = asyncio.Lock()
self._user_cache: dict[str, tuple[dict, float]] = {}
self._user_list_cache: list[dict] = []
self._user_list_age: float = 0
self._dept_cache: dict[str, tuple[dict, float]] = {}
self._dept_list_cache: list[dict] = []
self._dept_list_age: float = 0
async def get_user(self, user_id: str, *, user_id_type: str = "open_id") -> dict | None:
cache_key = f"{user_id_type}:{user_id}"
async with self._lock:
cached = self._user_cache.get(cache_key)
if cached is not None:
data, ts = cached
if time.monotonic() - ts < CACHE_TTL_USERS_S:
return data
path = f"/open-apis/contact/v3/users/{user_id}"
try:
resp = await self._api.get(path, user_id_type=user_id_type)
user = resp.get("data", {}).get("user", {})
except Exception:
return None
async with self._lock:
if user:
self._user_cache[cache_key] = (user, time.monotonic())
else:
self._user_cache.pop(cache_key, None)
return user
async def list_users(
self,
*,
department_id: str = "",
page_token: str = "",
page_size: int = 0,
) -> dict[str, Any]:
size = min(page_size or self._page_size, PAGE_SIZE_MAX)
path = "/open-apis/contact/v3/users"
params: dict[str, Any] = {"page_size": size}
if department_id:
params["department_id"] = department_id
if page_token:
params["page_token"] = page_token
if not department_id and not page_token:
async with self._lock:
if time.monotonic() - self._user_list_age < CACHE_TTL_USERS_S and self._user_list_cache:
return {"items": list(self._user_list_cache), "has_more": False, "page_token": ""}
resp = await self._api.get(path, **params)
data = resp.get("data", {})
items = data.get("items", [])
if not department_id and not page_token:
async with self._lock:
self._user_list_cache = items
self._user_list_age = time.monotonic()
return {
"items": items,
"has_more": data.get("has_more", False),
"page_token": data.get("page_token", ""),
}
async def get_department(self, department_id: str) -> dict | None:
cache_key = department_id
async with self._lock:
cached = self._dept_cache.get(cache_key)
if cached is not None:
data, ts = cached
if time.monotonic() - ts < CACHE_TTL_DEPARTMENTS_S:
return data
path = f"/open-apis/contact/v3/departments/{department_id}"
try:
resp = await self._api.get(path)
dept = resp.get("data", {}).get("department", {})
except Exception:
return None
async with self._lock:
if dept:
self._dept_cache[cache_key] = (dept, time.monotonic())
else:
self._dept_cache.pop(cache_key, None)
return dept
async def list_departments(
self,
*,
parent_department_id: str = "",
page_token: str = "",
page_size: int = 0,
) -> dict[str, Any]:
size = min(page_size or self._page_size, PAGE_SIZE_MAX)
path = "/open-apis/contact/v3/departments"
params: dict[str, Any] = {"page_size": size}
if parent_department_id:
params["parent_department_id"] = parent_department_id
if page_token:
params["page_token"] = page_token
if not parent_department_id and not page_token:
async with self._lock:
if time.monotonic() - self._dept_list_age < CACHE_TTL_DEPARTMENTS_S and self._dept_list_cache:
return {"items": list(self._dept_list_cache), "has_more": False, "page_token": ""}
resp = await self._api.get(path, **params)
data = resp.get("data", {})
items = data.get("items", [])
if not parent_department_id and not page_token:
async with self._lock:
self._dept_list_cache = items
self._dept_list_age = time.monotonic()
return {
"items": items,
"has_more": data.get("has_more", False),
"page_token": data.get("page_token", ""),
}
def invalidate_cache(self, scope: str = "all") -> None:
if scope in ("all", "users"):
self._user_cache.clear()
self._user_list_cache.clear()
self._user_list_age = 0
if scope in ("all", "departments"):
self._dept_cache.clear()
self._dept_list_cache.clear()
self._dept_list_age = 0
async def list_groups(client: Any) -> list[dict]:
resp = await client.chat.v1.list(page_size=200)
if not resp.success():
return []
items = []
data = resp.data
for group in getattr(data, "items", []) or []:
items.append(
{
"id": getattr(group, "chat_id", ""),
"name": getattr(group, "name", ""),
}
)
return items
async def list_peers(client: Any) -> list[dict]:
resp = await client.contact.v3.scope.list()
if not resp.success():
return []
items = []
data = resp.data
for user in getattr(data, "items", []) or []:
items.append(
{
"id": getattr(user, "open_id", ""),
"name": getattr(user, "name", ""),
}
)
return items