1. 移除多个文件中的空行、冗余导入
2. 修复文件末尾缺少换行符的问题
3. 新增并补全飞书多类工具API实现:
- 多维表格:更新、删除记录,列出视图
- 文档:更新、追加、删除块
- 云文档:上传、下载文件
- 群组:创建、添加成员、更新信息、创建公告
- 目录:重构用户部门缓存逻辑
4. 优化消息发送、回复、转发等API的错误处理和逻辑
5. 新增消息列表查询、已读状态查询等功能
185 lines
5.7 KiB
Python
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()
|