mirror of
https://github.com/HKUDS/OpenSpace.git
synced 2026-09-10 22:41:02 +00:00
901 lines
34 KiB
Python
901 lines
34 KiB
Python
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import contextlib
|
|
import hashlib
|
|
import hmac
|
|
import json
|
|
import threading
|
|
import time
|
|
from collections import OrderedDict, deque
|
|
from pathlib import Path
|
|
from typing import Any, Optional
|
|
|
|
from aiohttp import web
|
|
|
|
from openspace.communication.adapters.base import BaseChannelAdapter
|
|
from openspace.communication.attachment_cache import AttachmentCache
|
|
from openspace.communication.config import FeishuConfig
|
|
from openspace.communication.policy import is_authorized
|
|
from openspace.communication.types import (
|
|
AttachmentKind,
|
|
ChannelMessage,
|
|
ChannelPlatform,
|
|
ChannelSource,
|
|
SendResult,
|
|
)
|
|
from openspace.utils.logging import Logger
|
|
|
|
logger = Logger.get_logger(__name__)
|
|
|
|
_FEISHU_WEBHOOK_MAX_BODY_BYTES = 1 * 1024 * 1024
|
|
_FEISHU_WEBHOOK_READ_TIMEOUT_SECONDS = 30
|
|
_FEISHU_WEBHOOK_RATE_WINDOW_SECONDS = 60
|
|
_FEISHU_WEBHOOK_RATE_LIMIT_MAX = 120
|
|
_FEISHU_WEBHOOK_RATE_MAX_KEYS = 4096
|
|
_FEISHU_WEBHOOK_ANOMALY_TTL_SECONDS = 6 * 60 * 60
|
|
_FEISHU_DEDUP_CACHE_SIZE = 2048
|
|
_FEISHU_DEDUP_TTL_SECONDS = 24 * 60 * 60
|
|
|
|
try:
|
|
import lark_oapi as lark
|
|
from lark_oapi.api.im.v1 import (
|
|
CreateMessageRequest,
|
|
CreateMessageRequestBody,
|
|
GetMessageRequest,
|
|
GetMessageResourceRequest,
|
|
ReplyMessageRequest,
|
|
ReplyMessageRequestBody,
|
|
)
|
|
from lark_oapi.core.const import FEISHU_DOMAIN, LARK_DOMAIN
|
|
|
|
FEISHU_AVAILABLE = True
|
|
except ImportError:
|
|
FEISHU_AVAILABLE = False
|
|
lark = None # type: ignore[assignment]
|
|
CreateMessageRequest = None # type: ignore[assignment]
|
|
CreateMessageRequestBody = None # type: ignore[assignment]
|
|
GetMessageRequest = None # type: ignore[assignment]
|
|
GetMessageResourceRequest = None # type: ignore[assignment]
|
|
ReplyMessageRequest = None # type: ignore[assignment]
|
|
ReplyMessageRequestBody = None # type: ignore[assignment]
|
|
FEISHU_DOMAIN = None # type: ignore[assignment]
|
|
LARK_DOMAIN = None # type: ignore[assignment]
|
|
|
|
|
|
class FeishuAdapter(BaseChannelAdapter):
|
|
MAX_MESSAGE_LENGTH = 8000
|
|
_REPLY_CONTEXT_MAX_LEN = 200
|
|
|
|
def __init__(
|
|
self,
|
|
config: FeishuConfig,
|
|
attachment_cache: AttachmentCache,
|
|
*,
|
|
runtime_dir: Optional[Path] = None,
|
|
):
|
|
super().__init__(ChannelPlatform.FEISHU)
|
|
self.config = config
|
|
self.attachment_cache = attachment_cache
|
|
self.runtime_dir = (
|
|
Path(runtime_dir).expanduser().resolve()
|
|
if runtime_dir is not None
|
|
else attachment_cache.base_dir.parent.resolve()
|
|
)
|
|
self._client: Any = None
|
|
self._bot_open_id = config.bot_open_id
|
|
self._loop: Optional[asyncio.AbstractEventLoop] = None
|
|
self._ws_client: Any = None
|
|
self._ws_thread: Optional[threading.Thread] = None
|
|
self._running = False
|
|
self._dedup_state_path = self.runtime_dir / "feishu_seen_message_ids.json"
|
|
self._seen_message_ids: OrderedDict[str, float] = OrderedDict()
|
|
self._recent_sent_message_ids: OrderedDict[str, None] = OrderedDict()
|
|
self._rate_windows: dict[str, deque[float]] = {}
|
|
self._webhook_anomalies: dict[str, tuple[int, str, float]] = {}
|
|
self._dedup_dirty = False
|
|
self._load_seen_message_ids()
|
|
|
|
def register_http_routes(self, app: Any) -> None:
|
|
if self.config.connection_mode == "webhook":
|
|
app.router.add_post(self.config.webhook_path, self._handle_webhook)
|
|
|
|
def validate_configuration(self) -> None:
|
|
if self.config.connection_mode == "webhook" and not _optional_str(self.config.verification_token):
|
|
raise ValueError("Feishu webhook mode requires verification_token")
|
|
|
|
def get_lock_identity(self) -> Optional[tuple[str, str]]:
|
|
app_id = _optional_str(self.config.app_id)
|
|
if not app_id:
|
|
return None
|
|
return ("feishu-app", app_id)
|
|
|
|
async def connect(self) -> bool:
|
|
self.validate_configuration()
|
|
if not FEISHU_AVAILABLE:
|
|
logger.error("Feishu adapter requires lark-oapi")
|
|
return False
|
|
if not self.config.app_id or not self.config.app_secret:
|
|
logger.error("Feishu adapter missing app_id/app_secret")
|
|
return False
|
|
domain = FEISHU_DOMAIN if self.config.domain != "lark" else LARK_DOMAIN
|
|
self._client = (
|
|
lark.Client.builder()
|
|
.app_id(self.config.app_id)
|
|
.app_secret(self.config.app_secret)
|
|
.domain(domain)
|
|
.log_level(lark.LogLevel.WARNING)
|
|
.build()
|
|
)
|
|
self._running = True
|
|
self._loop = asyncio.get_running_loop()
|
|
if not self._bot_open_id:
|
|
self._bot_open_id = await asyncio.to_thread(self._fetch_bot_open_id)
|
|
if self.config.connection_mode == "websocket":
|
|
self._start_websocket_client()
|
|
self._connected = False
|
|
else:
|
|
self._connected = True
|
|
logger.info(
|
|
"Feishu adapter connected via %s mode",
|
|
self.config.connection_mode,
|
|
)
|
|
return True
|
|
|
|
async def disconnect(self) -> None:
|
|
self._running = False
|
|
self._connected = False
|
|
if self._ws_thread is not None and self._ws_thread.is_alive():
|
|
await asyncio.to_thread(self._ws_thread.join, 5)
|
|
self._ws_thread = None
|
|
self._persist_seen_message_ids()
|
|
self._client = None
|
|
|
|
async def send_text(
|
|
self,
|
|
chat_id: str,
|
|
content: str,
|
|
*,
|
|
reply_to_message_id: Optional[str] = None,
|
|
metadata: Optional[dict[str, Any]] = None,
|
|
) -> SendResult:
|
|
if not self._client:
|
|
return SendResult(success=False, error="Feishu client not initialized")
|
|
|
|
last_message_id: Optional[str] = None
|
|
for chunk in _split_text(content, self.MAX_MESSAGE_LENGTH):
|
|
payload = json.dumps({"text": chunk}, ensure_ascii=False)
|
|
if reply_to_message_id:
|
|
body = (
|
|
ReplyMessageRequestBody.builder()
|
|
.msg_type("text")
|
|
.content(payload)
|
|
.build()
|
|
)
|
|
request = (
|
|
ReplyMessageRequest.builder()
|
|
.message_id(reply_to_message_id)
|
|
.request_body(body)
|
|
.build()
|
|
)
|
|
response = await asyncio.to_thread(self._client.im.v1.message.reply, request)
|
|
else:
|
|
body = (
|
|
CreateMessageRequestBody.builder()
|
|
.receive_id(chat_id)
|
|
.msg_type("text")
|
|
.content(payload)
|
|
.build()
|
|
)
|
|
request = (
|
|
CreateMessageRequest.builder()
|
|
.receive_id_type("chat_id")
|
|
.request_body(body)
|
|
.build()
|
|
)
|
|
response = await asyncio.to_thread(self._client.im.v1.message.create, request)
|
|
|
|
if not response.success():
|
|
return SendResult(
|
|
success=False,
|
|
error=f"[{response.code}] {response.msg}",
|
|
raw_response=response,
|
|
)
|
|
last_message_id = getattr(getattr(response, "data", None), "message_id", None)
|
|
if last_message_id:
|
|
self._remember_sent_message_id(last_message_id)
|
|
return SendResult(success=True, message_id=last_message_id)
|
|
|
|
async def _handle_webhook(self, request: web.Request) -> web.Response:
|
|
remote_ip = _client_ip_from_request(request)
|
|
rate_key = f"{self.config.app_id}:{self.config.webhook_path}:{remote_ip}"
|
|
if not self._check_webhook_rate_limit(rate_key):
|
|
self._record_webhook_anomaly(remote_ip, "429")
|
|
return web.Response(status=429, text="Rate limit exceeded")
|
|
|
|
content_length = request.content_length or 0
|
|
if content_length > _FEISHU_WEBHOOK_MAX_BODY_BYTES:
|
|
self._record_webhook_anomaly(remote_ip, "413")
|
|
return web.Response(status=413, text="Payload too large")
|
|
|
|
try:
|
|
async with asyncio.timeout(_FEISHU_WEBHOOK_READ_TIMEOUT_SECONDS):
|
|
body_bytes = await request.read()
|
|
except TimeoutError:
|
|
self._record_webhook_anomaly(remote_ip, "408")
|
|
return web.Response(status=408, text="Request timeout")
|
|
|
|
if len(body_bytes) > _FEISHU_WEBHOOK_MAX_BODY_BYTES:
|
|
self._record_webhook_anomaly(remote_ip, "413")
|
|
return web.Response(status=413, text="Payload too large")
|
|
|
|
try:
|
|
payload = json.loads(body_bytes.decode("utf-8"))
|
|
except (UnicodeDecodeError, json.JSONDecodeError):
|
|
self._record_webhook_anomaly(remote_ip, "400")
|
|
return web.json_response({"code": 400, "msg": "invalid json"}, status=400)
|
|
|
|
incoming_token = str((payload.get("header") or {}).get("token") or payload.get("token") or "")
|
|
if not incoming_token or not hmac.compare_digest(incoming_token, self.config.verification_token or ""):
|
|
self._record_webhook_anomaly(remote_ip, "401-token")
|
|
return web.Response(status=401, text="Invalid verification token")
|
|
|
|
if self.config.encrypt_key and not _is_webhook_signature_valid(
|
|
encrypt_key=self.config.encrypt_key,
|
|
headers=request.headers,
|
|
body_bytes=body_bytes,
|
|
):
|
|
self._record_webhook_anomaly(remote_ip, "401-signature")
|
|
return web.Response(status=401, text="Invalid signature")
|
|
if payload.get("encrypt"):
|
|
self._record_webhook_anomaly(remote_ip, "400-encrypted")
|
|
return web.json_response(
|
|
{"code": 400, "msg": "encrypted webhook payloads are not supported"},
|
|
status=400,
|
|
)
|
|
|
|
self._clear_webhook_anomaly(remote_ip)
|
|
|
|
if payload.get("type") == "url_verification":
|
|
return web.json_response({"challenge": payload.get("challenge", "")})
|
|
|
|
event_type = str((payload.get("header") or {}).get("event_type") or "")
|
|
if event_type == "im.message.receive_v1":
|
|
await self._handle_message_event(payload)
|
|
return web.json_response({"code": 0, "msg": "ok"})
|
|
|
|
def _start_websocket_client(self) -> None:
|
|
assert lark is not None
|
|
|
|
handler = (
|
|
lark.EventDispatcherHandler.builder(
|
|
self.config.encrypt_key or "",
|
|
self.config.verification_token or "",
|
|
)
|
|
.register_p2_im_message_receive_v1(self._on_message_sync)
|
|
.build()
|
|
)
|
|
self._ws_client = lark.ws.Client(
|
|
self.config.app_id,
|
|
self.config.app_secret,
|
|
event_handler=handler,
|
|
log_level=lark.LogLevel.INFO,
|
|
)
|
|
|
|
def _run_ws_forever() -> None:
|
|
import lark_oapi.ws.client as lark_ws_client
|
|
|
|
ws_loop = asyncio.new_event_loop()
|
|
asyncio.set_event_loop(ws_loop)
|
|
lark_ws_client.loop = ws_loop
|
|
try:
|
|
while self._running:
|
|
try:
|
|
self._ws_client.start()
|
|
except Exception as exc:
|
|
self._connected = False
|
|
logger.warning("Feishu WebSocket client error: %s", exc)
|
|
if self._running:
|
|
time.sleep(5)
|
|
finally:
|
|
self._connected = False
|
|
ws_loop.close()
|
|
|
|
self._ws_thread = threading.Thread(
|
|
target=_run_ws_forever,
|
|
daemon=True,
|
|
name="openspace-feishu-ws",
|
|
)
|
|
self._ws_thread.start()
|
|
|
|
def _on_message_sync(self, data: Any) -> None:
|
|
if not self._loop or not self._running:
|
|
return
|
|
self._connected = True
|
|
asyncio.run_coroutine_threadsafe(
|
|
self._handle_websocket_message(data),
|
|
self._loop,
|
|
)
|
|
|
|
async def _handle_message_event(self, payload: dict[str, Any]) -> None:
|
|
normalized = await self._normalize_webhook_payload(payload)
|
|
if normalized is not None:
|
|
await self.dispatch_message(normalized)
|
|
|
|
async def _handle_websocket_message(self, data: Any) -> None:
|
|
normalized = await self._normalize_websocket_event(data)
|
|
if normalized is not None:
|
|
await self.dispatch_message(normalized)
|
|
|
|
async def _normalize_webhook_payload(self, payload: dict[str, Any]) -> Optional[ChannelMessage]:
|
|
event = payload.get("event") or {}
|
|
message = event.get("message") or {}
|
|
sender = event.get("sender") or {}
|
|
sender_id = sender.get("sender_id") or {}
|
|
if str(sender.get("sender_type", "")).lower() == "bot":
|
|
return None
|
|
return await self._normalize_inbound_message(
|
|
message_id=_optional_str(message.get("message_id")),
|
|
chat_id=_optional_str(message.get("chat_id")),
|
|
chat_type=_optional_str(message.get("chat_type")) or "p2p",
|
|
sender_uid=(
|
|
_optional_str(sender_id.get("open_id"))
|
|
or _optional_str(sender_id.get("user_id"))
|
|
or _optional_str(sender_id.get("union_id"))
|
|
),
|
|
sender_name=(
|
|
_optional_str(sender_id.get("name"))
|
|
or _optional_str(sender.get("sender_name"))
|
|
),
|
|
thread_id=_optional_str(message.get("thread_id")),
|
|
message_type=_optional_str(message.get("message_type")) or "",
|
|
content=_safe_json_loads(message.get("content", "{}")),
|
|
mentions=list(message.get("mentions") or []),
|
|
reply_to_message_id=(
|
|
_optional_str(message.get("parent_id"))
|
|
or _optional_str(message.get("upper_message_id"))
|
|
),
|
|
metadata={"webhook_payload": payload},
|
|
resolve_mentions=False,
|
|
)
|
|
|
|
async def _normalize_websocket_event(self, data: Any) -> Optional[ChannelMessage]:
|
|
event = getattr(data, "event", None)
|
|
message = getattr(event, "message", None)
|
|
sender = getattr(event, "sender", None)
|
|
if message is None or sender is None:
|
|
return None
|
|
if str(getattr(sender, "sender_type", "")).lower() == "bot":
|
|
return None
|
|
|
|
sender_id = getattr(sender, "sender_id", None)
|
|
return await self._normalize_inbound_message(
|
|
message_id=_optional_str(getattr(message, "message_id", None)),
|
|
chat_id=_optional_str(getattr(message, "chat_id", None)),
|
|
chat_type=_optional_str(getattr(message, "chat_type", None)) or "p2p",
|
|
sender_uid=(
|
|
_optional_str(getattr(sender_id, "open_id", None))
|
|
or _optional_str(getattr(sender_id, "user_id", None))
|
|
or _optional_str(getattr(sender_id, "union_id", None))
|
|
),
|
|
sender_name=_optional_str(getattr(sender, "sender_name", None)),
|
|
thread_id=_optional_str(getattr(message, "thread_id", None)),
|
|
message_type=_optional_str(getattr(message, "message_type", None)) or "",
|
|
content=_safe_json_loads(getattr(message, "content", "{}")),
|
|
mentions=list(getattr(message, "mentions", None) or []),
|
|
reply_to_message_id=(
|
|
_optional_str(getattr(message, "parent_id", None))
|
|
or _optional_str(getattr(message, "upper_message_id", None))
|
|
),
|
|
metadata={"websocket_event": True},
|
|
resolve_mentions=True,
|
|
)
|
|
|
|
async def _normalize_inbound_message(
|
|
self,
|
|
*,
|
|
message_id: Optional[str],
|
|
chat_id: Optional[str],
|
|
chat_type: str,
|
|
sender_uid: Optional[str],
|
|
sender_name: Optional[str],
|
|
thread_id: Optional[str],
|
|
message_type: str,
|
|
content: dict[str, Any],
|
|
mentions: list[Any],
|
|
reply_to_message_id: Optional[str],
|
|
metadata: dict[str, Any],
|
|
resolve_mentions: bool,
|
|
) -> Optional[ChannelMessage]:
|
|
if not message_id or not chat_id:
|
|
return None
|
|
if self._is_message_seen(message_id):
|
|
logger.debug("Skipping duplicate Feishu message %s", message_id)
|
|
return None
|
|
|
|
source = ChannelSource(
|
|
platform=ChannelPlatform.FEISHU,
|
|
chat_id=chat_id,
|
|
chat_type="dm" if str(chat_type).lower() == "p2p" else "group",
|
|
user_id=sender_uid,
|
|
user_name=sender_name,
|
|
chat_name=chat_id,
|
|
thread_id=thread_id,
|
|
)
|
|
session_key = _build_session_key_hint(source)
|
|
normalized_type = str(message_type or "").strip().lower()
|
|
mentions_bot = self._mentions_bot(mentions)
|
|
|
|
text = ""
|
|
if normalized_type == "text":
|
|
text = str(content.get("text", "")).strip()
|
|
if resolve_mentions:
|
|
text = _resolve_mentions(text, mentions)
|
|
elif normalized_type == "post":
|
|
text = _extract_post_text(content)
|
|
|
|
prefilter_message = ChannelMessage(
|
|
source=source,
|
|
text=text,
|
|
message_id=message_id,
|
|
reply_to_message_id=reply_to_message_id,
|
|
mentions_bot=mentions_bot,
|
|
metadata=metadata,
|
|
)
|
|
if not self._passes_prefilter(prefilter_message):
|
|
self._remember_message_seen(message_id)
|
|
return None
|
|
|
|
attachments = []
|
|
if normalized_type == "image":
|
|
attachment = await self._download_attachment(
|
|
session_key=session_key,
|
|
message_id=message_id,
|
|
file_key=str(content.get("image_key", "")).strip(),
|
|
file_name=str(content.get("image_key", "image")).strip() + ".png",
|
|
kind=AttachmentKind.IMAGE,
|
|
resource_type="image",
|
|
)
|
|
if attachment is not None:
|
|
attachments.append(attachment)
|
|
elif normalized_type == "file":
|
|
attachment = await self._download_attachment(
|
|
session_key=session_key,
|
|
message_id=message_id,
|
|
file_key=str(content.get("file_key", "")).strip(),
|
|
file_name=str(content.get("file_name", "document")).strip(),
|
|
kind=AttachmentKind.DOCUMENT,
|
|
resource_type="file",
|
|
)
|
|
if attachment is not None:
|
|
attachments.append(attachment)
|
|
|
|
prefilter_message.attachments = attachments
|
|
prefilter_message.reply_to_text = await self._fetch_message_text(reply_to_message_id)
|
|
self._remember_message_seen(message_id)
|
|
return prefilter_message
|
|
|
|
def _passes_prefilter(self, message: ChannelMessage) -> bool:
|
|
if not is_authorized(message, self.config):
|
|
logger.info("Rejected Feishu message from unauthorized user %s", message.source.user_id)
|
|
return False
|
|
if message.source.chat_type == "dm":
|
|
return self.config.allow_dm
|
|
if not self.config.allow_groups:
|
|
return False
|
|
if self.config.group_policy == "disabled":
|
|
return False
|
|
if self.config.group_policy == "mention_only":
|
|
return message.mentions_bot
|
|
if self.config.group_policy == "reply_or_mention":
|
|
return message.mentions_bot or self._is_reply_to_recent_bot_message(
|
|
message.reply_to_message_id
|
|
)
|
|
return True
|
|
|
|
def _is_reply_to_recent_bot_message(self, message_id: Optional[str]) -> bool:
|
|
if not message_id:
|
|
return False
|
|
return message_id in self._recent_sent_message_ids
|
|
|
|
def _remember_sent_message_id(self, message_id: str) -> None:
|
|
self._recent_sent_message_ids.pop(message_id, None)
|
|
self._recent_sent_message_ids[message_id] = None
|
|
while len(self._recent_sent_message_ids) > _FEISHU_DEDUP_CACHE_SIZE:
|
|
self._recent_sent_message_ids.popitem(last=False)
|
|
|
|
async def _download_attachment(
|
|
self,
|
|
*,
|
|
session_key: str,
|
|
message_id: str,
|
|
file_key: str,
|
|
file_name: str,
|
|
kind: AttachmentKind,
|
|
resource_type: str,
|
|
):
|
|
if not self._client or not file_key:
|
|
return None
|
|
request = (
|
|
GetMessageResourceRequest.builder()
|
|
.message_id(message_id)
|
|
.file_key(file_key)
|
|
.type(resource_type)
|
|
.build()
|
|
)
|
|
response = await asyncio.to_thread(self._client.im.v1.message_resource.get, request)
|
|
if not response.success():
|
|
logger.warning(
|
|
"Failed to download Feishu attachment: code=%s msg=%s",
|
|
response.code,
|
|
response.msg,
|
|
)
|
|
return None
|
|
try:
|
|
file_data = await asyncio.to_thread(
|
|
_read_attachment_body,
|
|
response.file,
|
|
self.attachment_cache.max_attachment_bytes,
|
|
)
|
|
except ValueError as exc:
|
|
logger.warning(
|
|
"Rejected Feishu attachment for session %s: %s",
|
|
session_key,
|
|
exc,
|
|
)
|
|
return None
|
|
return self.attachment_cache.save_bytes(
|
|
session_key=session_key,
|
|
data=file_data,
|
|
filename=file_name,
|
|
kind=kind,
|
|
)
|
|
|
|
def _fetch_bot_open_id(self) -> Optional[str]:
|
|
if not self._client or not lark:
|
|
return None
|
|
try:
|
|
request = (
|
|
lark.BaseRequest.builder()
|
|
.http_method(lark.HttpMethod.GET)
|
|
.uri("/open-apis/bot/v3/info")
|
|
.token_types({lark.AccessTokenType.APP})
|
|
.build()
|
|
)
|
|
response = self._client.request(request)
|
|
if not response.success():
|
|
logger.warning(
|
|
"Failed to fetch Feishu bot info: code=%s msg=%s",
|
|
response.code,
|
|
response.msg,
|
|
)
|
|
return None
|
|
payload = json.loads(response.raw.content)
|
|
bot = (payload.get("data") or payload).get("bot") or {}
|
|
return _optional_str(bot.get("open_id"))
|
|
except Exception as exc:
|
|
logger.warning("Failed to resolve Feishu bot open_id: %s", exc)
|
|
return None
|
|
|
|
async def _fetch_message_text(self, message_id: Optional[str]) -> Optional[str]:
|
|
if not self._client or not message_id or GetMessageRequest is None:
|
|
return None
|
|
|
|
request = GetMessageRequest.builder().message_id(message_id).build()
|
|
try:
|
|
response = await asyncio.to_thread(self._client.im.v1.message.get, request)
|
|
except Exception as exc:
|
|
logger.debug("Failed to fetch Feishu parent message %s: %s", message_id, exc)
|
|
return None
|
|
if not response.success():
|
|
return None
|
|
|
|
data = getattr(response, "data", None)
|
|
message_obj = None
|
|
items = getattr(data, "items", None)
|
|
if items:
|
|
message_obj = items[0]
|
|
elif data is not None:
|
|
message_obj = getattr(data, "message", None) or data
|
|
if message_obj is None:
|
|
return None
|
|
|
|
body = getattr(message_obj, "body", None)
|
|
raw_content = getattr(body, "content", None) if body is not None else getattr(message_obj, "content", None)
|
|
message_type = (
|
|
getattr(message_obj, "msg_type", None)
|
|
or getattr(message_obj, "message_type", None)
|
|
or ""
|
|
)
|
|
content = _safe_json_loads(raw_content)
|
|
text = ""
|
|
if str(message_type).lower() == "text":
|
|
text = str(content.get("text", "")).strip()
|
|
elif str(message_type).lower() == "post":
|
|
text = _extract_post_text(content)
|
|
if not text:
|
|
return None
|
|
if len(text) > self._REPLY_CONTEXT_MAX_LEN:
|
|
text = text[: self._REPLY_CONTEXT_MAX_LEN] + "..."
|
|
return text
|
|
|
|
def _mentions_bot(self, mentions: list[Any]) -> bool:
|
|
if not mentions:
|
|
return False
|
|
if not self._bot_open_id:
|
|
return False
|
|
|
|
for mention in mentions:
|
|
mention_id = (mention.get("id") or {}) if isinstance(mention, dict) else getattr(mention, "id", None)
|
|
open_id = (
|
|
_optional_str(mention_id.get("open_id"))
|
|
if isinstance(mention_id, dict)
|
|
else _optional_str(getattr(mention_id, "open_id", None))
|
|
)
|
|
if open_id == self._bot_open_id:
|
|
return True
|
|
return False
|
|
|
|
def _load_seen_message_ids(self) -> None:
|
|
if not self._dedup_state_path.exists():
|
|
return
|
|
try:
|
|
payload = json.loads(self._dedup_state_path.read_text(encoding="utf-8"))
|
|
except (OSError, json.JSONDecodeError):
|
|
logger.warning("Failed to load Feishu dedup cache from %s", self._dedup_state_path)
|
|
return
|
|
entries = payload.get("message_ids", {}) if isinstance(payload, dict) else {}
|
|
now = time.time()
|
|
valid: list[tuple[str, float]] = []
|
|
if isinstance(entries, dict):
|
|
for message_id, seen_at in entries.items():
|
|
normalized_id = _optional_str(message_id)
|
|
if not normalized_id:
|
|
continue
|
|
try:
|
|
timestamp = float(seen_at)
|
|
except (TypeError, ValueError):
|
|
continue
|
|
if now - timestamp <= _FEISHU_DEDUP_TTL_SECONDS:
|
|
valid.append((normalized_id, timestamp))
|
|
for message_id, seen_at in sorted(valid, key=lambda item: item[1])[-_FEISHU_DEDUP_CACHE_SIZE:]:
|
|
self._seen_message_ids[message_id] = seen_at
|
|
|
|
def _persist_seen_message_ids(self) -> None:
|
|
if not self._dedup_dirty:
|
|
return
|
|
self._dedup_state_path.parent.mkdir(parents=True, exist_ok=True)
|
|
payload = {"message_ids": dict(self._seen_message_ids)}
|
|
try:
|
|
self._dedup_state_path.write_text(
|
|
json.dumps(payload, ensure_ascii=False, indent=2),
|
|
encoding="utf-8",
|
|
)
|
|
except OSError:
|
|
logger.warning("Failed to persist Feishu dedup cache to %s", self._dedup_state_path)
|
|
return
|
|
self._dedup_dirty = False
|
|
|
|
def _is_message_seen(self, message_id: str) -> bool:
|
|
now = time.time()
|
|
self._prune_seen_message_ids(now)
|
|
return message_id in self._seen_message_ids
|
|
|
|
def _remember_message_seen(self, message_id: str) -> None:
|
|
now = time.time()
|
|
self._prune_seen_message_ids(now)
|
|
if message_id in self._seen_message_ids:
|
|
self._seen_message_ids.move_to_end(message_id)
|
|
return
|
|
self._seen_message_ids[message_id] = now
|
|
self._seen_message_ids.move_to_end(message_id)
|
|
while len(self._seen_message_ids) > _FEISHU_DEDUP_CACHE_SIZE:
|
|
self._seen_message_ids.popitem(last=False)
|
|
self._dedup_dirty = True
|
|
self._persist_seen_message_ids()
|
|
|
|
def _mark_message_seen(self, message_id: str) -> bool:
|
|
if self._is_message_seen(message_id):
|
|
return True
|
|
self._remember_message_seen(message_id)
|
|
return False
|
|
|
|
def _prune_seen_message_ids(self, now: Optional[float] = None) -> None:
|
|
current = now or time.time()
|
|
stale = [
|
|
message_id
|
|
for message_id, seen_at in self._seen_message_ids.items()
|
|
if current - seen_at > _FEISHU_DEDUP_TTL_SECONDS
|
|
]
|
|
for message_id in stale:
|
|
self._seen_message_ids.pop(message_id, None)
|
|
self._dedup_dirty = True
|
|
|
|
def _check_webhook_rate_limit(self, rate_key: str) -> bool:
|
|
now = time.time()
|
|
window = self._rate_windows.get(rate_key)
|
|
if window is None:
|
|
if len(self._rate_windows) >= _FEISHU_WEBHOOK_RATE_MAX_KEYS:
|
|
stale_keys = [
|
|
key
|
|
for key, timestamps in self._rate_windows.items()
|
|
if not timestamps or now - timestamps[-1] > _FEISHU_WEBHOOK_RATE_WINDOW_SECONDS
|
|
]
|
|
for key in stale_keys:
|
|
self._rate_windows.pop(key, None)
|
|
if rate_key not in self._rate_windows and len(self._rate_windows) >= _FEISHU_WEBHOOK_RATE_MAX_KEYS:
|
|
return False
|
|
window = deque()
|
|
self._rate_windows[rate_key] = window
|
|
cutoff = now - _FEISHU_WEBHOOK_RATE_WINDOW_SECONDS
|
|
while window and window[0] < cutoff:
|
|
window.popleft()
|
|
if len(window) >= _FEISHU_WEBHOOK_RATE_LIMIT_MAX:
|
|
return False
|
|
window.append(now)
|
|
return True
|
|
|
|
def _record_webhook_anomaly(self, remote_ip: str, status: str) -> None:
|
|
now = time.time()
|
|
current = self._webhook_anomalies.get(remote_ip)
|
|
if current and now - current[2] < _FEISHU_WEBHOOK_ANOMALY_TTL_SECONDS:
|
|
self._webhook_anomalies[remote_ip] = (current[0] + 1, status, current[2])
|
|
return
|
|
self._webhook_anomalies[remote_ip] = (1, status, now)
|
|
|
|
def _clear_webhook_anomaly(self, remote_ip: str) -> None:
|
|
self._webhook_anomalies.pop(remote_ip, None)
|
|
|
|
|
|
def _safe_json_loads(value: Any) -> dict[str, Any]:
|
|
if isinstance(value, dict):
|
|
return value
|
|
try:
|
|
return json.loads(str(value or "{}"))
|
|
except json.JSONDecodeError:
|
|
return {}
|
|
|
|
|
|
def _optional_str(value: Any) -> Optional[str]:
|
|
if value is None:
|
|
return None
|
|
value = str(value).strip()
|
|
return value or None
|
|
|
|
|
|
def _client_ip_from_request(request: web.Request) -> str:
|
|
forwarded = str(request.headers.get("x-forwarded-for", "") or "").split(",")[0].strip()
|
|
if forwarded:
|
|
return forwarded
|
|
peer = request.transport.get_extra_info("peername") if request.transport else None
|
|
if isinstance(peer, tuple) and peer:
|
|
return str(peer[0])
|
|
return request.remote or "unknown"
|
|
|
|
|
|
def _resolve_mentions(text: str, mentions: list[Any]) -> str:
|
|
if not text or not mentions:
|
|
return text
|
|
|
|
resolved = text
|
|
for mention in mentions:
|
|
key = _optional_str(
|
|
mention.get("key") if isinstance(mention, dict) else getattr(mention, "key", None)
|
|
)
|
|
if not key or key not in resolved:
|
|
continue
|
|
name = _optional_str(
|
|
mention.get("name") if isinstance(mention, dict) else getattr(mention, "name", None)
|
|
) or "user"
|
|
resolved = resolved.replace(key, f"@{name}")
|
|
return resolved
|
|
|
|
|
|
def _split_text(content: str, limit: int) -> list[str]:
|
|
text = content.strip()
|
|
if not text:
|
|
return [""]
|
|
if len(text) <= limit:
|
|
return [text]
|
|
chunks = []
|
|
remaining = text
|
|
while remaining:
|
|
chunk = remaining[:limit]
|
|
if len(remaining) > limit:
|
|
split_at = chunk.rfind("\n")
|
|
if split_at < limit // 3:
|
|
split_at = chunk.rfind(" ")
|
|
if split_at >= limit // 3:
|
|
chunk = chunk[:split_at]
|
|
chunks.append(chunk.strip())
|
|
remaining = remaining[len(chunk):].lstrip()
|
|
return [chunk for chunk in chunks if chunk]
|
|
|
|
|
|
def _is_webhook_signature_valid(*, encrypt_key: str, headers: Any, body_bytes: bytes) -> bool:
|
|
timestamp = str(headers.get("x-lark-request-timestamp", "") or "")
|
|
nonce = str(headers.get("x-lark-request-nonce", "") or "")
|
|
signature = str(headers.get("x-lark-signature", "") or "")
|
|
if not timestamp or not nonce or not signature:
|
|
return False
|
|
content = f"{timestamp}{nonce}{encrypt_key}{body_bytes.decode('utf-8', errors='replace')}"
|
|
expected = hashlib.sha256(content.encode("utf-8")).hexdigest()
|
|
return hmac.compare_digest(signature, expected)
|
|
|
|
|
|
def _build_session_key_hint(source: ChannelSource) -> str:
|
|
parts = [source.platform.value, source.chat_id]
|
|
if source.thread_id:
|
|
parts.append(source.thread_id)
|
|
return "__".join(part.replace("/", "_") for part in parts if part)
|
|
|
|
|
|
def _extract_post_text(content: dict[str, Any]) -> str:
|
|
texts: list[str] = []
|
|
|
|
def _walk(value: Any) -> None:
|
|
if isinstance(value, dict):
|
|
title = _optional_str(value.get("title"))
|
|
if title:
|
|
texts.append(title)
|
|
tag = _optional_str(value.get("tag"))
|
|
if tag == "at":
|
|
user_name = _optional_str(value.get("user_name")) or "user"
|
|
texts.append(f"@{user_name}")
|
|
else:
|
|
text = _optional_str(value.get("text"))
|
|
if text:
|
|
texts.append(text)
|
|
for nested in value.values():
|
|
_walk(nested)
|
|
elif isinstance(value, list):
|
|
for item in value:
|
|
_walk(item)
|
|
|
|
_walk(content)
|
|
deduped: list[str] = []
|
|
for text in texts:
|
|
if text not in deduped:
|
|
deduped.append(text)
|
|
return "\n".join(deduped).strip()
|
|
|
|
|
|
def _read_attachment_body(raw_file: Any, max_bytes: int) -> bytes:
|
|
if raw_file is None:
|
|
return b""
|
|
|
|
if isinstance(raw_file, bytes):
|
|
data = raw_file
|
|
elif isinstance(raw_file, bytearray):
|
|
data = bytes(raw_file)
|
|
elif hasattr(raw_file, "read"):
|
|
chunks: list[bytes] = []
|
|
total = 0
|
|
try:
|
|
while True:
|
|
chunk = raw_file.read(65536)
|
|
if not chunk:
|
|
break
|
|
if isinstance(chunk, str):
|
|
chunk = chunk.encode("utf-8")
|
|
elif isinstance(chunk, bytearray):
|
|
chunk = bytes(chunk)
|
|
elif not isinstance(chunk, bytes):
|
|
chunk = bytes(chunk)
|
|
total += len(chunk)
|
|
if total > max_bytes:
|
|
raise ValueError(
|
|
f"attachment size {total} exceeds limit {max_bytes}"
|
|
)
|
|
chunks.append(chunk)
|
|
finally:
|
|
close = getattr(raw_file, "close", None)
|
|
if callable(close):
|
|
close()
|
|
data = b"".join(chunks)
|
|
else:
|
|
data = bytes(raw_file)
|
|
|
|
if len(data) > max_bytes:
|
|
raise ValueError(f"attachment size {len(data)} exceeds limit {max_bytes}")
|
|
return data
|