1. 新建常量模块统一管理飞书插件共享常量,拆分重复定义的配置与标识 2. 重构配置读取逻辑,统一使用常量化的渠道标识 3. 修复登录适配器配置项键名错误,将qr_login_redirect_uri改为qr_redirect_uri 4. 补充出站适配器续发分片的参数支持与日志逻辑 5. 优化入站适配器的账户ID处理逻辑,修正资源下载URL的账户标识 6. 更新manifest配置,补充传输模式与能力需求声明 7. 完善注释说明,修正文档与实际代码不一致的问题
838 lines
27 KiB
Python
838 lines
27 KiB
Python
"""飞书 API HTTP 客户端。
|
||
|
||
通过 httpx.AsyncClient 调用飞书开放平台 API,封装 tenant_access_token
|
||
管理、通用请求与 IM / 通讯录 / OAuth API。HTTP 异常与飞书业务错误码
|
||
通过 error_translator 转换为契约层异常,不向外抛原生异常。token 通过
|
||
CachePort 缓存(key 为 feishu:token:{app_id},TTL=expires_in-60s),
|
||
客户端本身不持有 mutable 状态。
|
||
|
||
连接池由 lifecycle 统一管理(onInit 创建 / onUnload 关闭),通过
|
||
``attach_http_client`` 注入;未注入时降级为每请求独立 client(兼容
|
||
测试与未启用 lifecycle 的场景)。
|
||
|
||
依赖方向:仅 import yuxi.channels.contract.* 与标准库 / httpx,不污染
|
||
框架层。error_translator 为本插件内部模块(任务 2 产出)。
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import json
|
||
import time
|
||
from typing import Any
|
||
|
||
import httpx
|
||
|
||
from yuxi.channels.contract.dtos.config import ConfigScope
|
||
from yuxi.channels.contract.errors import DependencyError
|
||
from yuxi.channels.contract.ports.driven.cache_port import CachePort
|
||
from yuxi.channels.contract.ports.driven.config_port import ConfigPort
|
||
from yuxi.channels.contract.ports.driven.logger_port import LoggerPort
|
||
|
||
from . import error_translator
|
||
from ._constants import CHANNEL_TARGET
|
||
|
||
_TOKEN_CACHE_KEY_PREFIX = "feishu:token:"
|
||
_APP_TOKEN_CACHE_KEY_PREFIX = "feishu:app_token:"
|
||
_TOKEN_REFRESH_LEAD_TIME_SEC = 60
|
||
|
||
|
||
class FeishuClient:
|
||
"""飞书 API HTTP 客户端。
|
||
|
||
通过 DI 接收 ConfigPort / CachePort / LoggerPort,不访问全局 settings
|
||
或 logger。token 通过 CachePort 缓存,客户端不持有 mutable 状态。所有
|
||
方法为 async,HTTP 异常通过 error_translator.wrap_http_exception 转换,
|
||
飞书业务错误码非 0 通过 error_translator.translate_feishu_error 转换。
|
||
|
||
连接池由 lifecycle 统一管理(onInit 创建 / onUnload 关闭),通过
|
||
``attach_http_client`` 注入;未注入时降级为每请求独立 client(兼容
|
||
测试与未启用 lifecycle 的场景)。
|
||
"""
|
||
|
||
def __init__(
|
||
self,
|
||
config_port: ConfigPort,
|
||
cache_port: CachePort,
|
||
logger_port: LoggerPort,
|
||
) -> None:
|
||
self._config = config_port
|
||
self._cache = cache_port
|
||
self._logger = logger_port
|
||
# 由 lifecycle 注入的共享连接池;为 None 时降级为每请求独立 client
|
||
self._http_client: httpx.AsyncClient | None = None
|
||
|
||
# ------------------------------------------------------------------
|
||
# 连接池管理
|
||
# ------------------------------------------------------------------
|
||
|
||
def attach_http_client(self, client: httpx.AsyncClient) -> None:
|
||
"""注入 lifecycle 管理的共享 httpx 连接池。
|
||
|
||
由 ``FeishuLifecycleHandler.onInit`` 调用,将创建好的连接池注入
|
||
客户端;``onUnload`` 关闭前由 lifecycle 置空以避免悬挂引用。
|
||
|
||
@consistency
|
||
- 幂等:重复调用以最后一次注入的 client 为准
|
||
"""
|
||
self._http_client = client
|
||
|
||
def detach_http_client(self) -> None:
|
||
"""解除共享连接池引用(由 lifecycle 在 onUnload 关闭前调用)。
|
||
|
||
@consistency
|
||
- 幂等:重复调用安全
|
||
"""
|
||
self._http_client = None
|
||
|
||
# ------------------------------------------------------------------
|
||
# 内部辅助
|
||
# ------------------------------------------------------------------
|
||
|
||
async def _get_account_config(self, account_id: str) -> dict[str, Any]:
|
||
"""读取账户级配置(app_id / app_secret)。"""
|
||
cv_app_id = await self._config.get(
|
||
"app_id",
|
||
scope=ConfigScope.ACCOUNT,
|
||
target=account_id,
|
||
)
|
||
cv_app_secret = await self._config.get(
|
||
"app_secret",
|
||
scope=ConfigScope.ACCOUNT,
|
||
target=account_id,
|
||
)
|
||
return {
|
||
"app_id": cv_app_id.value,
|
||
"app_secret": cv_app_secret.value,
|
||
}
|
||
|
||
async def _get_channel_config(self, key: str) -> Any:
|
||
"""读取渠道级配置(api_base_url / request_timeout_ms)。"""
|
||
cv = await self._config.get(
|
||
key,
|
||
scope=ConfigScope.CHANNEL,
|
||
target=CHANNEL_TARGET,
|
||
)
|
||
return cv.value
|
||
|
||
async def _get_request_timeout_seconds(self) -> float:
|
||
"""从配置读取请求超时(秒)。"""
|
||
timeout_ms = await self._get_channel_config("request_timeout_ms")
|
||
return float(timeout_ms) / 1000.0
|
||
|
||
def _token_cache_key(self, app_id: str) -> str:
|
||
return f"{_TOKEN_CACHE_KEY_PREFIX}{app_id}"
|
||
|
||
async def _get_token_for_account(self, account_id: str) -> str:
|
||
"""获取账户的 tenant_access_token。"""
|
||
account_config = await self._get_account_config(account_id)
|
||
token, _ = await self.get_tenant_access_token(
|
||
account_config["app_id"],
|
||
account_config["app_secret"],
|
||
)
|
||
return token
|
||
|
||
async def _build_auth_header(self, account_id: str) -> dict[str, str]:
|
||
"""获取账户 token 并构造 Authorization 头。"""
|
||
token = await self._get_token_for_account(account_id)
|
||
return {"Authorization": f"Bearer {token}"}
|
||
|
||
async def _execute_http(
|
||
self,
|
||
method: str,
|
||
url: str,
|
||
headers: dict[str, str],
|
||
*,
|
||
json_body: dict | None = None,
|
||
params: dict | None = None,
|
||
data: dict | None = None,
|
||
files: dict | None = None,
|
||
) -> httpx.Response:
|
||
"""执行 HTTP 请求,HTTP 异常转换为契约异常。
|
||
|
||
不负责 token 注入与业务错误码校验,由调用方处理。优先复用 lifecycle
|
||
注入的共享连接池;未注入时降级为每请求独立 ``httpx.AsyncClient``
|
||
(兼容测试与未启用 lifecycle 的场景)。HTTP 异常经
|
||
``error_translator.wrap_http_exception`` 转换,并传入实际配置
|
||
的 ``timeout_ms`` 保证 ``TimeoutError`` 携带准确超时值。
|
||
"""
|
||
timeout = await self._get_request_timeout_seconds()
|
||
try:
|
||
if self._http_client is not None:
|
||
resp = await self._http_client.request(
|
||
method,
|
||
url,
|
||
json=json_body,
|
||
params=params,
|
||
data=data,
|
||
files=files,
|
||
headers=headers,
|
||
timeout=timeout,
|
||
)
|
||
else:
|
||
# 连接池未注入时降级为每请求独立 client(兼容测试场景)
|
||
async with httpx.AsyncClient(timeout=timeout) as client:
|
||
resp = await client.request(
|
||
method,
|
||
url,
|
||
json=json_body,
|
||
params=params,
|
||
data=data,
|
||
files=files,
|
||
headers=headers,
|
||
)
|
||
resp.raise_for_status()
|
||
except httpx.HTTPError as exc:
|
||
await self._logger.exception(
|
||
"Feishu HTTP request failed",
|
||
exc_info=exc,
|
||
method=method,
|
||
url=url,
|
||
)
|
||
raise error_translator.wrap_http_exception(
|
||
exc,
|
||
timeout_ms=int(timeout * 1000),
|
||
) from exc
|
||
return resp
|
||
|
||
@staticmethod
|
||
def _parse_json_response(resp: httpx.Response) -> dict:
|
||
"""解析 JSON 响应,校验飞书业务错误码。
|
||
|
||
code 非 0 时通过 error_translator.translate_feishu_error 转换为
|
||
契约异常。code 为 0 时返回完整响应字典。
|
||
"""
|
||
data = resp.json()
|
||
code = data.get("code", 0)
|
||
if code != 0:
|
||
raise error_translator.translate_feishu_error(
|
||
code,
|
||
data.get("msg", ""),
|
||
) from None
|
||
return data
|
||
|
||
async def _request_json(
|
||
self,
|
||
method: str,
|
||
path: str,
|
||
account_id: str,
|
||
*,
|
||
json_body: dict | None = None,
|
||
params: dict | None = None,
|
||
data: dict | None = None,
|
||
files: dict | None = None,
|
||
) -> dict:
|
||
"""发起带 tenant token 的请求并返回校验后的 JSON。"""
|
||
headers = await self._build_auth_header(account_id)
|
||
api_base_url = await self._get_channel_config("api_base_url")
|
||
url = f"{api_base_url}{path}"
|
||
resp = await self._execute_http(
|
||
method,
|
||
url,
|
||
headers,
|
||
json_body=json_body,
|
||
params=params,
|
||
data=data,
|
||
files=files,
|
||
)
|
||
return self._parse_json_response(resp)
|
||
|
||
# ------------------------------------------------------------------
|
||
# Token 管理
|
||
# ------------------------------------------------------------------
|
||
|
||
async def get_tenant_access_token(
|
||
self,
|
||
app_id: str,
|
||
app_secret: str,
|
||
) -> tuple[str, int]:
|
||
"""获取 tenant_access_token,缓存命中返回剩余 TTL。
|
||
|
||
缓存写入时存储绝对过期时间戳 ``expires_at``,缓存命中时根据
|
||
``expires_at - now`` 计算剩余 TTL,避免长期持有 token 的调用方
|
||
误判有效期。``expires_in <= 0`` 表示已过期,调用方应触发重新获取。
|
||
|
||
@return (token, remaining_expires_in)
|
||
"""
|
||
cache_key = self._token_cache_key(app_id)
|
||
cached = await self._cache.get(cache_key)
|
||
if cached.is_some():
|
||
token, expires_at = cached.unwrap()
|
||
remaining = max(int(expires_at - time.time()), 0)
|
||
return token, remaining
|
||
|
||
api_base_url = await self._get_channel_config("api_base_url")
|
||
url = f"{api_base_url}/open-apis/auth/v3/tenant_access_token/internal"
|
||
body = {"app_id": app_id, "app_secret": app_secret}
|
||
|
||
resp = await self._execute_http(
|
||
"POST",
|
||
url,
|
||
headers={},
|
||
json_body=body,
|
||
)
|
||
data = self._parse_json_response(resp)
|
||
token = data["tenant_access_token"]
|
||
expires_in = int(data.get("expire", 0))
|
||
if expires_in <= 0:
|
||
raise DependencyError(
|
||
dep="feishu_api",
|
||
cause=ValueError(f"invalid expire value: {expires_in}"),
|
||
)
|
||
expires_at = time.time() + expires_in
|
||
ttl = max(expires_in - _TOKEN_REFRESH_LEAD_TIME_SEC, 1)
|
||
await self._cache.set(
|
||
cache_key,
|
||
(token, expires_at),
|
||
ttl_seconds=ttl,
|
||
)
|
||
await self._logger.debug(
|
||
"Feishu tenant_access_token refreshed",
|
||
app_id=app_id,
|
||
expires_in=expires_in,
|
||
)
|
||
return token, expires_in
|
||
|
||
async def get_app_access_token(
|
||
self,
|
||
app_id: str,
|
||
app_secret: str,
|
||
) -> str:
|
||
"""获取 app_access_token,缓存命中返回剩余有效期内的 token。
|
||
|
||
app_access_token 用于调用飞书 OIDC(用户令牌交换)等需要应用级
|
||
授权的端点,与 tenant_access_token(租户级)语义不同。缓存策略
|
||
对齐 ``get_tenant_access_token``:存储绝对过期时间戳,TTL 提前
|
||
``_TOKEN_REFRESH_LEAD_TIME_SEC`` 秒刷新。
|
||
|
||
@return app_access_token 字符串。
|
||
"""
|
||
cache_key = f"{_APP_TOKEN_CACHE_KEY_PREFIX}{app_id}"
|
||
cached = await self._cache.get(cache_key)
|
||
if cached.is_some():
|
||
token, expires_at = cached.unwrap()
|
||
if expires_at > time.time():
|
||
return token
|
||
|
||
api_base_url = await self._get_channel_config("api_base_url")
|
||
url = f"{api_base_url}/open-apis/auth/v3/app_access_token/internal"
|
||
body = {"app_id": app_id, "app_secret": app_secret}
|
||
|
||
resp = await self._execute_http(
|
||
"POST",
|
||
url,
|
||
headers={},
|
||
json_body=body,
|
||
)
|
||
data = self._parse_json_response(resp)
|
||
token = data["app_access_token"]
|
||
expires_in = int(data.get("expire", 0))
|
||
if expires_in <= 0:
|
||
raise DependencyError(
|
||
dep="feishu_api",
|
||
cause=ValueError(f"invalid expire value: {expires_in}"),
|
||
)
|
||
expires_at = time.time() + expires_in
|
||
ttl = max(expires_in - _TOKEN_REFRESH_LEAD_TIME_SEC, 1)
|
||
await self._cache.set(
|
||
cache_key,
|
||
(token, expires_at),
|
||
ttl_seconds=ttl,
|
||
)
|
||
await self._logger.debug(
|
||
"Feishu app_access_token refreshed",
|
||
app_id=app_id,
|
||
expires_in=expires_in,
|
||
)
|
||
return token
|
||
|
||
# ------------------------------------------------------------------
|
||
# 通用请求
|
||
# ------------------------------------------------------------------
|
||
|
||
async def request(
|
||
self,
|
||
method: str,
|
||
path: str,
|
||
account_id: str,
|
||
*,
|
||
json: dict | None = None,
|
||
params: dict | None = None,
|
||
receive_id_type: str | None = None,
|
||
) -> dict:
|
||
"""通用请求方法。
|
||
|
||
自动获取 tenant_access_token,注入 Authorization 头,发起 HTTP
|
||
请求并校验业务错误码。receive_id_type 非空时作为查询参数注入。
|
||
"""
|
||
all_params: dict[str, Any] = dict(params) if params else {}
|
||
if receive_id_type is not None:
|
||
all_params["receive_id_type"] = receive_id_type
|
||
return await self._request_json(
|
||
method,
|
||
path,
|
||
account_id,
|
||
json_body=json,
|
||
params=all_params or None,
|
||
)
|
||
|
||
# ------------------------------------------------------------------
|
||
# IM API
|
||
# ------------------------------------------------------------------
|
||
|
||
async def send_message(
|
||
self,
|
||
account_id: str,
|
||
receive_id: str,
|
||
msg_type: str,
|
||
content: str,
|
||
receive_id_type: str = "open_id",
|
||
) -> dict:
|
||
"""发送消息。"""
|
||
return await self.request(
|
||
"POST",
|
||
"/open-apis/im/v1/messages",
|
||
account_id,
|
||
json={
|
||
"receive_id": receive_id,
|
||
"msg_type": msg_type,
|
||
"content": content,
|
||
},
|
||
receive_id_type=receive_id_type,
|
||
)
|
||
|
||
async def update_message(
|
||
self,
|
||
account_id: str,
|
||
message_id: str,
|
||
content: str,
|
||
) -> dict:
|
||
"""更新消息。"""
|
||
return await self.request(
|
||
"PATCH",
|
||
f"/open-apis/im/v1/messages/{message_id}",
|
||
account_id,
|
||
json={"content": content},
|
||
)
|
||
|
||
async def recall_message(
|
||
self,
|
||
account_id: str,
|
||
message_id: str,
|
||
) -> dict:
|
||
"""撤回消息。"""
|
||
return await self.request(
|
||
"DELETE",
|
||
f"/open-apis/im/v1/messages/{message_id}",
|
||
account_id,
|
||
)
|
||
|
||
async def get_message(
|
||
self,
|
||
account_id: str,
|
||
message_id: str,
|
||
) -> dict:
|
||
"""查询消息。"""
|
||
return await self.request(
|
||
"GET",
|
||
f"/open-apis/im/v1/messages/{message_id}",
|
||
account_id,
|
||
)
|
||
|
||
async def reply_message(
|
||
self,
|
||
account_id: str,
|
||
message_id: str,
|
||
msg_type: str,
|
||
content: str,
|
||
) -> dict:
|
||
"""回复消息。"""
|
||
return await self.request(
|
||
"POST",
|
||
f"/open-apis/im/v1/messages/{message_id}/reply",
|
||
account_id,
|
||
json={"msg_type": msg_type, "content": content},
|
||
)
|
||
|
||
async def batch_get_messages(
|
||
self,
|
||
account_id: str,
|
||
message_ids: list[str],
|
||
) -> dict:
|
||
"""批量查询消息。"""
|
||
return await self.request(
|
||
"GET",
|
||
"/open-apis/im/v1/messages/batch_get",
|
||
account_id,
|
||
params={"message_ids": ",".join(message_ids)},
|
||
)
|
||
|
||
async def update_card(
|
||
self,
|
||
account_id: str,
|
||
message_id: str,
|
||
card_content: dict,
|
||
) -> dict:
|
||
"""更新卡片消息。
|
||
|
||
飞书 PATCH 接口期望 ``content`` 为 JSON 字符串,对齐
|
||
``send_message`` 的 ``msg_type=interactive`` 投递约定。
|
||
"""
|
||
return await self.request(
|
||
"PATCH",
|
||
f"/open-apis/im/v1/messages/{message_id}",
|
||
account_id,
|
||
json={"content": json.dumps(card_content)},
|
||
)
|
||
|
||
async def streaming_start(
|
||
self,
|
||
account_id: str,
|
||
receive_id: str,
|
||
content: dict,
|
||
receive_id_type: str = "open_id",
|
||
) -> dict:
|
||
"""开启流式消息。
|
||
|
||
飞书流式接口期望 ``content`` 为 JSON 字符串,对齐
|
||
``send_message`` 与 ``update_card`` 的序列化约定。
|
||
"""
|
||
return await self.request(
|
||
"POST",
|
||
"/open-apis/im/v1/messages/streaming",
|
||
account_id,
|
||
json={
|
||
"receive_id": receive_id,
|
||
"content": json.dumps(content),
|
||
},
|
||
receive_id_type=receive_id_type,
|
||
)
|
||
|
||
async def streaming_update(
|
||
self,
|
||
account_id: str,
|
||
message_id: str,
|
||
content: dict,
|
||
) -> dict:
|
||
"""更新流式消息。
|
||
|
||
飞书流式接口期望 ``content`` 为 JSON 字符串,对齐
|
||
``send_message`` 与 ``update_card`` 的序列化约定。
|
||
"""
|
||
return await self.request(
|
||
"PATCH",
|
||
f"/open-apis/im/v1/messages/streaming/{message_id}",
|
||
account_id,
|
||
json={"content": json.dumps(content)},
|
||
)
|
||
|
||
async def streaming_stop(
|
||
self,
|
||
account_id: str,
|
||
message_id: str,
|
||
content: dict,
|
||
) -> dict:
|
||
"""结束流式消息。
|
||
|
||
飞书流式接口期望 ``content`` 为 JSON 字符串,对齐
|
||
``send_message`` 与 ``update_card`` 的序列化约定。
|
||
"""
|
||
return await self.request(
|
||
"POST",
|
||
f"/open-apis/im/v1/messages/streaming/{message_id}/finish",
|
||
account_id,
|
||
json={"content": json.dumps(content)},
|
||
)
|
||
|
||
async def add_reaction(
|
||
self,
|
||
account_id: str,
|
||
message_id: str,
|
||
emoji_type: str,
|
||
) -> dict:
|
||
"""添加表情回应。"""
|
||
return await self.request(
|
||
"POST",
|
||
f"/open-apis/im/v1/messages/{message_id}/reactions",
|
||
account_id,
|
||
json={"reaction_type": {"emoji_type": emoji_type}},
|
||
)
|
||
|
||
async def delete_reaction(
|
||
self,
|
||
account_id: str,
|
||
message_id: str,
|
||
reaction_id: str,
|
||
) -> dict:
|
||
"""删除表情回应。"""
|
||
return await self.request(
|
||
"DELETE",
|
||
f"/open-apis/im/v1/messages/{message_id}/reactions/{reaction_id}",
|
||
account_id,
|
||
)
|
||
|
||
async def pin_message(
|
||
self,
|
||
account_id: str,
|
||
message_id: str,
|
||
) -> dict:
|
||
"""Pin 消息。"""
|
||
return await self.request(
|
||
"POST",
|
||
"/open-apis/im/v1/pins",
|
||
account_id,
|
||
json={"message_id": message_id},
|
||
)
|
||
|
||
async def unpin_message(
|
||
self,
|
||
account_id: str,
|
||
message_id: str,
|
||
) -> dict:
|
||
"""取消 Pin 消息。"""
|
||
return await self.request(
|
||
"DELETE",
|
||
f"/open-apis/im/v1/pins/{message_id}",
|
||
account_id,
|
||
)
|
||
|
||
async def get_message_read_info(
|
||
self,
|
||
account_id: str,
|
||
message_id: str,
|
||
) -> dict:
|
||
"""查询消息已读信息。
|
||
|
||
飞书返回 ``data.read_users`` 列表,含 ``user_id`` / ``read_time``。
|
||
群消息已读人数 = ``data.read_count``,目标总数 = ``data.target_count``。
|
||
"""
|
||
return await self.request(
|
||
"GET",
|
||
f"/open-apis/im/v1/messages/{message_id}/read_info",
|
||
account_id,
|
||
)
|
||
|
||
async def upload_image(
|
||
self,
|
||
account_id: str,
|
||
image_data: bytes,
|
||
image_type: str = "message",
|
||
) -> dict:
|
||
"""上传图片(multipart/form-data)。"""
|
||
return await self._request_json(
|
||
"POST",
|
||
"/open-apis/im/v1/images",
|
||
account_id,
|
||
data={"image_type": image_type},
|
||
files={"image": ("image.png", image_data)},
|
||
)
|
||
|
||
async def upload_file(
|
||
self,
|
||
account_id: str,
|
||
file_data: bytes,
|
||
file_name: str,
|
||
file_type: str = "stream",
|
||
) -> dict:
|
||
"""上传文件(multipart/form-data)。"""
|
||
return await self._request_json(
|
||
"POST",
|
||
"/open-apis/im/v1/files",
|
||
account_id,
|
||
data={"file_type": file_type, "file_name": file_name},
|
||
files={"file": (file_name, file_data)},
|
||
)
|
||
|
||
async def download_resource(
|
||
self,
|
||
account_id: str,
|
||
message_id: str,
|
||
file_key: str,
|
||
file_type: str = "file",
|
||
) -> bytes:
|
||
"""下载资源文件,返回二进制内容。"""
|
||
headers = await self._build_auth_header(account_id)
|
||
api_base_url = await self._get_channel_config("api_base_url")
|
||
url = f"{api_base_url}/open-apis/im/v1/messages/{message_id}/resources/{file_key}"
|
||
resp = await self._execute_http(
|
||
"GET",
|
||
url,
|
||
headers,
|
||
params={"type": file_type},
|
||
)
|
||
return resp.content
|
||
|
||
# ------------------------------------------------------------------
|
||
# 通讯录 API
|
||
# ------------------------------------------------------------------
|
||
|
||
async def get_user(
|
||
self,
|
||
account_id: str,
|
||
user_id: str,
|
||
user_id_type: str = "open_id",
|
||
) -> dict:
|
||
"""查询用户信息。"""
|
||
return await self.request(
|
||
"GET",
|
||
f"/open-apis/contact/v3/users/{user_id}",
|
||
account_id,
|
||
params={"user_id_type": user_id_type},
|
||
)
|
||
|
||
async def search_users(
|
||
self,
|
||
account_id: str,
|
||
query: str,
|
||
page_size: int = 50,
|
||
page_token: str | None = None,
|
||
) -> dict:
|
||
"""搜索用户。"""
|
||
params: dict[str, Any] = {"query": query, "page_size": page_size}
|
||
if page_token is not None:
|
||
params["page_token"] = page_token
|
||
return await self.request(
|
||
"GET",
|
||
"/open-apis/search/v1/user",
|
||
account_id,
|
||
params=params,
|
||
)
|
||
|
||
async def get_group_list(
|
||
self,
|
||
account_id: str,
|
||
page_size: int = 50,
|
||
page_token: str | None = None,
|
||
) -> dict:
|
||
"""查询群列表。"""
|
||
params: dict[str, Any] = {"page_size": page_size}
|
||
if page_token is not None:
|
||
params["page_token"] = page_token
|
||
return await self.request(
|
||
"GET",
|
||
"/open-apis/im/v1/chats",
|
||
account_id,
|
||
params=params,
|
||
)
|
||
|
||
async def get_group_members(
|
||
self,
|
||
account_id: str,
|
||
group_id: str,
|
||
page_size: int = 50,
|
||
page_token: str | None = None,
|
||
) -> dict:
|
||
"""查询群成员。"""
|
||
params: dict[str, Any] = {"page_size": page_size}
|
||
if page_token is not None:
|
||
params["page_token"] = page_token
|
||
return await self.request(
|
||
"GET",
|
||
f"/open-apis/im/v1/chats/{group_id}/members",
|
||
account_id,
|
||
params=params,
|
||
)
|
||
|
||
async def get_group_detail(
|
||
self,
|
||
account_id: str,
|
||
group_id: str,
|
||
) -> dict:
|
||
"""查询群详情。"""
|
||
return await self.request(
|
||
"GET",
|
||
f"/open-apis/im/v1/chats/{group_id}",
|
||
account_id,
|
||
)
|
||
|
||
# ------------------------------------------------------------------
|
||
# OAuth API
|
||
# ------------------------------------------------------------------
|
||
|
||
async def get_qr_login_url(
|
||
self,
|
||
account_id: str,
|
||
redirect_uri: str,
|
||
state: str,
|
||
) -> dict:
|
||
"""获取扫码登录 URL。"""
|
||
return await self.request(
|
||
"POST",
|
||
"/open-apis/authen/v1/index",
|
||
account_id,
|
||
json={"redirect_uri": redirect_uri, "state": state},
|
||
)
|
||
|
||
async def poll_qr_login_status(
|
||
self,
|
||
account_id: str,
|
||
login_id: str,
|
||
) -> dict:
|
||
"""轮询扫码登录状态。"""
|
||
return await self.request(
|
||
"GET",
|
||
f"/open-apis/authen/v1/login_state/{login_id}",
|
||
account_id,
|
||
)
|
||
|
||
async def revoke_token(
|
||
self,
|
||
account_id: str,
|
||
user_access_token: str,
|
||
) -> dict:
|
||
"""撤销用户 access token。
|
||
|
||
使用 user_access_token 而非 tenant_access_token 进行认证,
|
||
account_id 仅用于定位渠道级 api_base_url。请求体需携带被撤销的
|
||
``access_token``,对齐飞书 authen/v1/access_tokens/revoke 协议。
|
||
"""
|
||
api_base_url = await self._get_channel_config("api_base_url")
|
||
url = f"{api_base_url}/open-apis/authen/v1/access_tokens/revoke"
|
||
headers = {"Authorization": f"Bearer {user_access_token}"}
|
||
resp = await self._execute_http(
|
||
"POST",
|
||
url,
|
||
headers,
|
||
json_body={"access_token": user_access_token},
|
||
)
|
||
return self._parse_json_response(resp)
|
||
|
||
async def exchange_oauth_code(
|
||
self,
|
||
app_id: str,
|
||
app_secret: str,
|
||
code: str,
|
||
redirect_uri: str,
|
||
) -> dict:
|
||
"""用 OAuth 授权码交换 user_access_token。
|
||
|
||
两步流程:
|
||
1. 通过 ``get_app_access_token`` 获取 app_access_token(带缓存)。
|
||
2. 用 app_access_token 调用 OIDC token 交换端点
|
||
``/open-apis/authen/v1/oidc/access_token``,传入
|
||
``grant_type=authorization_code`` 与授权码。
|
||
|
||
与 ``request`` 方法不同:OIDC 端点要求 app_access_token(应用级),
|
||
而非 tenant_access_token(租户级),因此不能复用 ``_build_auth_header``。
|
||
|
||
@return 飞书响应字典,``data`` 字段含 ``access_token`` /
|
||
``refresh_token`` / ``expires_in``。
|
||
"""
|
||
app_access_token = await self.get_app_access_token(app_id, app_secret)
|
||
api_base_url = await self._get_channel_config("api_base_url")
|
||
url = f"{api_base_url}/open-apis/authen/v1/oidc/access_token"
|
||
headers = {"Authorization": f"Bearer {app_access_token}"}
|
||
resp = await self._execute_http(
|
||
"POST",
|
||
url,
|
||
headers,
|
||
json_body={
|
||
"grant_type": "authorization_code",
|
||
"code": code,
|
||
"redirect_uri": redirect_uri,
|
||
},
|
||
)
|
||
return self._parse_json_response(resp)
|
||
|
||
|
||
__all__ = ["FeishuClient"]
|