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

185 lines
5.7 KiB
Python

from __future__ import annotations
import json
import os
import time
from enum import StrEnum
CHAT_ID_PREFIX_DM = "feishu:dm:"
CHAT_ID_PREFIX_GROUP = "feishu:group:"
CHAT_ID_PREFIX_THREAD = "feishu:thread:"
CHAT_ID_PREFIX_GROUP_SENDER = "feishu:group_sender:"
CHAT_ID_PREFIX_GROUP_TOPIC = "feishu:group_topic:"
CHAT_ID_PREFIX_GROUP_TOPIC_SENDER = "feishu:group_topic_sender:"
GroupSessionScope = str
class SessionMode(StrEnum):
RAW = "raw"
THREAD = "thread"
CHAT_RAW = "chat_raw"
CHAT_RESOLVE = "chat_resolve"
def generate_feishu_chat_id(
raw_chat_id: str,
chat_type: str,
*,
root_id: str | None = None,
sender_id: str | None = None,
mode: SessionMode = SessionMode.CHAT_RESOLVE,
scope: GroupSessionScope = "group",
) -> str:
if mode == SessionMode.RAW:
return raw_chat_id
if mode == SessionMode.CHAT_RAW:
prefix = f"feishu:{chat_type}:"
return f"{prefix}{raw_chat_id}"
if mode == SessionMode.THREAD and root_id:
return f"{CHAT_ID_PREFIX_THREAD}{root_id}"
if chat_type in ("group", "thread") and root_id:
if scope == "group_topic_sender" and sender_id:
return f"{CHAT_ID_PREFIX_GROUP_TOPIC_SENDER}{root_id}:{raw_chat_id}:{sender_id}"
if scope == "group_topic":
return f"{CHAT_ID_PREFIX_GROUP_TOPIC}{root_id}:{raw_chat_id}"
if scope == "group_sender" and sender_id:
return f"{CHAT_ID_PREFIX_GROUP_SENDER}{raw_chat_id}:{sender_id}"
return f"{CHAT_ID_PREFIX_GROUP}{root_id}:{raw_chat_id}"
if chat_type == "group" or chat_type == "direct" or chat_type == "p2p":
if scope == "group_sender" and sender_id:
prefix = CHAT_ID_PREFIX_DM if (chat_type == "direct" or chat_type == "p2p") else CHAT_ID_PREFIX_GROUP_SENDER
return f"{prefix}{raw_chat_id}:{sender_id}"
prefix = CHAT_ID_PREFIX_DM if (chat_type == "direct" or chat_type == "p2p") else CHAT_ID_PREFIX_GROUP
return f"{prefix}{raw_chat_id}"
return f"{CHAT_ID_PREFIX_DM}{raw_chat_id}"
def resolve_feishu_chat_type(raw_chat_id: str) -> str:
return "direct" if raw_chat_id.startswith("ou_") else "group"
def resolve_feishu_command_conversation(
chat_id: str,
user_id: str,
*,
scope: GroupSessionScope = "group",
) -> str:
if user_id.startswith("ou_"):
return f"{CHAT_ID_PREFIX_DM}{user_id}"
return generate_feishu_chat_id(chat_id, "group", scope=scope)
def normalize_feishu_acp_conversation_id(conv_id: str) -> str:
if conv_id.startswith("feishu:"):
return conv_id
if conv_id.startswith("ou_"):
return f"{CHAT_ID_PREFIX_DM}{conv_id}"
if conv_id.startswith("oc_"):
return f"{CHAT_ID_PREFIX_GROUP}{conv_id}"
return conv_id
def match_feishu_acp_conversation(conv_id: str, pattern: str) -> bool:
import fnmatch
normalized = normalize_feishu_acp_conversation_id(conv_id)
return fnmatch.fnmatch(normalized, pattern)
_FEISHU_ID_PREFIXES = ("ou_", "oc_", "on_", "om_", "od_", "og_", "tg_")
def looks_like_feishu_id(value: str) -> bool:
if not value or not isinstance(value, str):
return False
return any(value.startswith(p) for p in _FEISHU_ID_PREFIXES)
def normalize_feishu_target(target: str) -> str:
if not target or not isinstance(target, str):
return target
target = target.strip()
if target.startswith("feishu:"):
return target
if target.startswith("lark:"):
return "feishu:" + target[5:]
if target.startswith("feishu://"):
return "feishu:" + target[9:]
for prefix in _FEISHU_ID_PREFIXES:
if target.startswith(prefix):
if prefix in ("ou_", "on_"):
return f"{CHAT_ID_PREFIX_DM}{target}"
if prefix in ("oc_", "om_", "od_", "og_"):
return f"{CHAT_ID_PREFIX_GROUP}{target}"
return target
class SessionPersistence:
def __init__(self, persist_path: str = ""):
self._persist_path = persist_path
self._data: dict[str, dict] = {}
if persist_path and os.path.exists(persist_path):
self._load()
def _load(self) -> None:
try:
with open(self._persist_path, encoding="utf-8") as f:
self._data = json.load(f)
except (json.JSONDecodeError, OSError):
self._data = {}
def _save(self) -> None:
if not self._persist_path:
return
try:
tmp = self._persist_path + ".tmp"
with open(tmp, "w", encoding="utf-8") as f:
json.dump(self._data, f, ensure_ascii=False)
os.replace(tmp, self._persist_path)
except OSError:
pass
def get(self, session_id: str, key: str) -> str | None:
session = self._data.get(session_id, {})
return session.get(key)
def set(self, session_id: str, key: str, value: str) -> None:
if session_id not in self._data:
self._data[session_id] = {}
self._data[session_id][key] = value
self._save()
def delete(self, session_id: str, key: str = "") -> None:
if key:
session = self._data.get(session_id, {})
session.pop(key, None)
if not session:
self._data.pop(session_id, None)
else:
self._data.pop(session_id, None)
self._save()
def cleanup_expired(self, max_age_s: float = 86400) -> None:
now = time.time()
expired = []
for sid, data in self._data.items():
last_active = data.get("_last_active", 0)
if isinstance(last_active, (int, float)) and now - last_active > max_age_s:
expired.append(sid)
for sid in expired:
self._data.pop(sid, None)
if expired:
self._save()