da15b70535
Closes #392 (uncontroversial part). WeChat (`_handle_message`) and Feishu (`_handle_event`) gated their signature/decryption checks behind a condition the REQUEST controls: - WeChat: `if encrypt and self._crypto:` -- a POST with no `<Encrypt>` element took the false branch and reached `_safe_process_message` without any verification, even when `encoding_aes_key` + `token` were configured. - Feishu: `if self.config.encrypt_key and "encrypt" in body:` -- a plaintext body skipped decryption entirely and was processed directly. Since the webhook port is the channel's only inbound boundary, an attacker could POST forged plaintext and reach the agent, spoofing `sender_id` / `FromUserName` (and, with an empty allowlist, passing the sender gate). Fix: when encryption is configured, an inbound POST MUST carry the encrypted field (`<Encrypt>` / `encrypt`) -- otherwise it is rejected with 403 and never reaches the agent. Plaintext mode (no encryption configured) is unchanged, so existing plaintext deployments are not affected. The remaining fail-closed question (what to do when credentials are entirely unset) is left for the maintainers to decide as the policy part of the issue. Regression tests (9 new): - WeChat: plaintext rejected / missing Encrypt rejected / bad signature rejected / valid signature decrypts and processes / plaintext still accepted when no crypto. - Feishu: plaintext rejected / non-dict body rejected / encrypted body decrypts and processes / plaintext still accepted when no encrypt_key. 93 tests in the two channel files pass; full suite 3045 passed, 13 skipped; ruff clean. Co-authored-by: Xi Zhang <106144707+X-iZhang@users.noreply.github.com>
1091 lines
40 KiB
Python
1091 lines
40 KiB
Python
"""Feishu (飞书/Lark) channel implementation.
|
|
|
|
Receives messages via HTTP event subscription webhook (aiohttp) or
|
|
WebSocket long connection (lark-oapi SDK), sends replies via Feishu
|
|
Open API REST endpoints.
|
|
|
|
Feishu Open API docs: https://open.feishu.cn/document
|
|
|
|
Authentication:
|
|
- App ID + App Secret → tenant_access_token (2-hour TTL, auto-refreshed)
|
|
|
|
Event subscription (two modes):
|
|
- **Webhook**: URL verification challenge on first request,
|
|
``im.message.receive_v1`` events via HTTP POST callback.
|
|
Requires a publicly reachable URL.
|
|
- **WebSocket**: outbound long connection via ``lark-oapi`` SDK.
|
|
No public IP required.
|
|
|
|
Send API:
|
|
- ``POST /open-apis/im/v1/messages?receive_id_type=chat_id``
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import base64
|
|
import hashlib
|
|
import json
|
|
import logging
|
|
import queue
|
|
import re
|
|
import threading
|
|
from dataclasses import dataclass
|
|
from datetime import datetime
|
|
from pathlib import Path
|
|
from typing import TYPE_CHECKING, Any, ClassVar
|
|
|
|
if TYPE_CHECKING:
|
|
from aiohttp import web
|
|
|
|
from ..base import Channel, ChannelError, RawIncoming
|
|
from ..capabilities import FEISHU as FEISHU_CAPS
|
|
from ..config import BaseChannelConfig
|
|
from ..mixins import TokenMixin, WebhookMixin
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
# ── Markdown → Feishu Post conversion ────────────────────────────
|
|
|
|
|
|
def _parse_inline_text(text: str) -> list[dict]:
|
|
"""Parse inline Markdown elements into Feishu post tag dicts.
|
|
|
|
Handles: `code`, **bold**, ~~strikethrough~~, [link](url), _italic_.
|
|
"""
|
|
elements: list[dict] = []
|
|
# Pattern order matters: code first (protect content), then bold, strikethrough, link, italic
|
|
pattern = re.compile(
|
|
r"`([^`]+)`" # inline code
|
|
r"|\*\*(.+?)\*\*" # bold
|
|
r"|~~(.+?)~~" # strikethrough
|
|
r"|\[([^\]]+)\]\(([^)]+)\)" # link
|
|
r"|_(.+?)_" # italic
|
|
)
|
|
pos = 0
|
|
for m in pattern.finditer(text):
|
|
# Plain text before this match
|
|
if m.start() > pos:
|
|
elements.append({"tag": "text", "text": text[pos : m.start()]})
|
|
|
|
if m.group(1) is not None:
|
|
# inline code → code_block would be block-level; use text with style
|
|
elements.append(
|
|
{
|
|
"tag": "text",
|
|
"text": m.group(1),
|
|
"style": ["code_block"],
|
|
}
|
|
)
|
|
elif m.group(2) is not None:
|
|
elements.append(
|
|
{
|
|
"tag": "text",
|
|
"text": m.group(2),
|
|
"style": ["bold"],
|
|
}
|
|
)
|
|
elif m.group(3) is not None:
|
|
elements.append(
|
|
{
|
|
"tag": "text",
|
|
"text": m.group(3),
|
|
"style": ["strikethrough"],
|
|
}
|
|
)
|
|
elif m.group(4) is not None:
|
|
elements.append(
|
|
{
|
|
"tag": "a",
|
|
"text": m.group(4),
|
|
"href": m.group(5),
|
|
}
|
|
)
|
|
elif m.group(6) is not None:
|
|
elements.append(
|
|
{
|
|
"tag": "text",
|
|
"text": m.group(6),
|
|
"style": ["italic"],
|
|
}
|
|
)
|
|
pos = m.end()
|
|
|
|
# Remaining plain text
|
|
if pos < len(text):
|
|
elements.append({"tag": "text", "text": text[pos:]})
|
|
return elements
|
|
|
|
|
|
def _parse_inline_elements(line: str) -> list[dict]:
|
|
"""Parse a single Markdown line into a list of Feishu post elements.
|
|
|
|
Handles headings (→ bold), blockquotes (→ italic with prefix),
|
|
list items (→ bullet prefix), and plain lines.
|
|
"""
|
|
# Heading: # Title → bold text
|
|
heading_match = re.match(r"^(#{1,6})\s+(.+)$", line)
|
|
if heading_match:
|
|
return [{"tag": "text", "text": heading_match.group(2), "style": ["bold"]}]
|
|
|
|
# Blockquote: > text → italic with "▎" prefix
|
|
quote_match = re.match(r"^>\s*(.*)$", line)
|
|
if quote_match:
|
|
inner = quote_match.group(1)
|
|
elements = [{"tag": "text", "text": "▎", "style": ["italic"]}]
|
|
elements.extend(_parse_inline_text(inner))
|
|
return elements
|
|
|
|
# Unordered list: - item or * item → "• " prefix
|
|
list_match = re.match(r"^[\-\*]\s+(.+)$", line)
|
|
if list_match:
|
|
elements = [{"tag": "text", "text": "• "}]
|
|
elements.extend(_parse_inline_text(list_match.group(1)))
|
|
return elements
|
|
|
|
# Ordered list: 1. item → keep number prefix
|
|
ol_match = re.match(r"^(\d+)\.\s+(.+)$", line)
|
|
if ol_match:
|
|
elements = [{"tag": "text", "text": f"{ol_match.group(1)}. "}]
|
|
elements.extend(_parse_inline_text(ol_match.group(2)))
|
|
return elements
|
|
|
|
# Plain line
|
|
return _parse_inline_text(line)
|
|
|
|
|
|
def _markdown_to_feishu_post(text: str) -> dict | None:
|
|
"""Convert Markdown text to Feishu post (rich text) JSON structure.
|
|
|
|
Returns a dict like {"zh_cn": {"content": [[...]]}} suitable for
|
|
Feishu msg_type="post", or None if the text is empty.
|
|
"""
|
|
if not text or not text.strip():
|
|
return None
|
|
|
|
paragraphs: list[list[dict]] = []
|
|
current_paragraph: list[dict] = []
|
|
in_code_block = False
|
|
code_lines: list[str] = []
|
|
code_lang = ""
|
|
|
|
for line in text.split("\n"):
|
|
# Code block fences
|
|
if line.startswith("```"):
|
|
if not in_code_block:
|
|
# Flush any pending paragraph
|
|
if current_paragraph:
|
|
paragraphs.append(current_paragraph)
|
|
current_paragraph = []
|
|
in_code_block = True
|
|
code_lang = line[3:].strip()
|
|
code_lines = []
|
|
else:
|
|
# End of code block
|
|
code_text = "\n".join(code_lines)
|
|
paragraphs.append(
|
|
[
|
|
{
|
|
"tag": "code_block",
|
|
"language": code_lang or "plain",
|
|
"text": code_text,
|
|
}
|
|
]
|
|
)
|
|
in_code_block = False
|
|
code_lines = []
|
|
code_lang = ""
|
|
continue
|
|
|
|
if in_code_block:
|
|
code_lines.append(line)
|
|
continue
|
|
|
|
# Empty line → new paragraph
|
|
if not line.strip():
|
|
if current_paragraph:
|
|
paragraphs.append(current_paragraph)
|
|
current_paragraph = []
|
|
continue
|
|
|
|
# Non-empty line
|
|
elements = _parse_inline_elements(line)
|
|
if elements:
|
|
# Each visual line becomes its own paragraph in Feishu post
|
|
if current_paragraph:
|
|
paragraphs.append(current_paragraph)
|
|
current_paragraph = elements
|
|
|
|
# Flush remaining
|
|
if in_code_block and code_lines:
|
|
code_text = "\n".join(code_lines)
|
|
paragraphs.append(
|
|
[
|
|
{
|
|
"tag": "code_block",
|
|
"language": code_lang or "plain",
|
|
"text": code_text,
|
|
}
|
|
]
|
|
)
|
|
elif current_paragraph:
|
|
paragraphs.append(current_paragraph)
|
|
|
|
if not paragraphs:
|
|
return None
|
|
|
|
return {"zh_cn": {"content": paragraphs}}
|
|
|
|
|
|
@dataclass
|
|
class FeishuConfig(BaseChannelConfig):
|
|
app_id: str = ""
|
|
app_secret: str = ""
|
|
verification_token: str = ""
|
|
encrypt_key: str = ""
|
|
webhook_port: int = 9000
|
|
text_chunk_limit: int = 4096
|
|
feishu_domain: str = "https://open.feishu.cn"
|
|
subscription_mode: str = "webhook" # "webhook" | "websocket"
|
|
|
|
|
|
class FeishuChannel(Channel, WebhookMixin, TokenMixin):
|
|
capabilities = FEISHU_CAPS
|
|
"""Feishu channel using Open API + event subscription webhook."""
|
|
|
|
name = "feishu"
|
|
_ready_attrs = ("_http_client", "_access_token")
|
|
_non_retryable_patterns = (
|
|
"app_access_token is empty", # invalid credentials
|
|
"10003", # invalid app_id
|
|
"10014", # invalid app_secret
|
|
"99991401", # permission denied
|
|
"99991663", # no permission
|
|
"99991672", # feature not enabled
|
|
)
|
|
_rate_limit_patterns = ("99991400", "rate limit", "频率限制")
|
|
_rate_limit_delay = 2.0
|
|
|
|
def __init__(self, config: FeishuConfig):
|
|
super().__init__(config)
|
|
self._mention_names: list[str] = [] # bot mention keys from events
|
|
self._main_loop: asyncio.AbstractEventLoop | None = None
|
|
self._lark_ws_thread: threading.Thread | None = None
|
|
self._ws_event_queue: queue.Queue | None = None
|
|
self._ws_consumer_task: asyncio.Task | None = None
|
|
|
|
# ── WebhookMixin overrides ────────────────────────────────────
|
|
|
|
def _get_webhook_port(self) -> int:
|
|
return self.config.webhook_port
|
|
|
|
def _webhook_routes(self) -> list[tuple[str, Any]]:
|
|
return [("POST", "/webhook/event", self._handle_event)]
|
|
|
|
# ── TokenMixin overrides ──────────────────────────────────────
|
|
|
|
async def _fetch_token(self) -> tuple[str, int]:
|
|
"""Fetch Feishu tenant_access_token."""
|
|
url = f"{self.config.feishu_domain}/open-apis/auth/v3/tenant_access_token/internal"
|
|
body = {
|
|
"app_id": self.config.app_id,
|
|
"app_secret": self.config.app_secret,
|
|
}
|
|
try:
|
|
resp = await self._http_client.post(url, json=body)
|
|
data = resp.json()
|
|
except Exception as e:
|
|
raise ChannelError(f"Failed to get Feishu access token: {e}") from e
|
|
|
|
if data.get("code") != 0:
|
|
raise ChannelError(f"Feishu auth error: {data.get('msg', 'unknown')}")
|
|
return data["tenant_access_token"], data.get("expire", 7200)
|
|
|
|
# ── Lifecycle ─────────────────────────────────────────────────
|
|
|
|
_VALID_SUBSCRIPTION_MODES = ("webhook", "websocket")
|
|
|
|
async def start(self) -> None:
|
|
if not self.config.app_id:
|
|
raise ChannelError("Feishu app_id is required")
|
|
if not self.config.app_secret:
|
|
raise ChannelError("Feishu app_secret is required")
|
|
if self.config.subscription_mode not in self._VALID_SUBSCRIPTION_MODES:
|
|
raise ChannelError(
|
|
f"Invalid feishu_subscription_mode: {self.config.subscription_mode!r}. "
|
|
f"Must be one of {self._VALID_SUBSCRIPTION_MODES}"
|
|
)
|
|
|
|
if self.config.subscription_mode == "websocket":
|
|
await self._start_websocket_mode()
|
|
else:
|
|
await self._start_webhook_mode()
|
|
|
|
async def _start_webhook_mode(self) -> None:
|
|
try:
|
|
import httpx # noqa: F401
|
|
from aiohttp import web # noqa: F401
|
|
except ImportError:
|
|
raise ChannelError(
|
|
"aiohttp or httpx not installed. "
|
|
"Install with: pip install aiohttp httpx"
|
|
) from None
|
|
|
|
# Start webhook server (sets up self._http_client)
|
|
await self._start_webhook_server()
|
|
|
|
# Verify credentials by fetching initial token
|
|
await self._refresh_token()
|
|
|
|
self._running = True
|
|
logger.info(
|
|
f"Feishu channel started (webhook on port {self.config.webhook_port})"
|
|
)
|
|
|
|
async def _start_websocket_mode(self) -> None:
|
|
try:
|
|
import lark_oapi as lark
|
|
except ImportError:
|
|
raise ChannelError(
|
|
"lark-oapi not installed. Install with: pip install 'lark-oapi>=1.4.0'"
|
|
) from None
|
|
|
|
import httpx
|
|
|
|
proxy = getattr(self.config, "proxy", None) or None
|
|
self._http_client = httpx.AsyncClient(timeout=15, proxy=proxy)
|
|
|
|
# Verify credentials by fetching initial token
|
|
await self._refresh_token()
|
|
|
|
self._main_loop = asyncio.get_running_loop()
|
|
|
|
# Thread-safe queue: SDK thread puts events, main loop consumes
|
|
self._ws_event_queue = queue.Queue()
|
|
|
|
# Set _running BEFORE creating consumer task — the task checks
|
|
# `while self._running` and would exit immediately otherwise.
|
|
self._running = True
|
|
self._ws_consumer_task = asyncio.create_task(self._consume_ws_events())
|
|
|
|
# Build SDK event handler
|
|
handler = (
|
|
lark.EventDispatcherHandler.builder("", "")
|
|
.register_p2_im_message_receive_v1(self._on_lark_sdk_message)
|
|
.build()
|
|
)
|
|
|
|
# Silently absorb events we don't have a handler for. Feishu auto-
|
|
# subscribes a PersonalAgent app to many event types (reactions,
|
|
# read receipts, recalls, member changes…) that EvoScientist doesn't
|
|
# care about. Without this wrapper, ``_do_without_validation``
|
|
# raises ``EventException("processor not found, type: ...")``,
|
|
# which lark-oapi's WS client (ws/client.py) catches and turns into
|
|
# an HTTP 500 reply on the WebSocket frame — Feishu then marks the
|
|
# event as failed and retries it. This is especially noisy because
|
|
# our own ``_send_ack_reaction`` triggers ``im.message.reaction.
|
|
# created_v1`` on every inbound message, causing a feedback loop.
|
|
from lark_oapi.core.exception import EventException
|
|
|
|
_original_dispatch = handler._do_without_validation
|
|
|
|
def _silent_dispatch(payload: bytes):
|
|
try:
|
|
return _original_dispatch(payload)
|
|
except EventException as exc:
|
|
if "processor not found" in str(exc):
|
|
logger.debug("Feishu: ignored unsubscribed event (%s)", exc)
|
|
return None
|
|
raise
|
|
|
|
handler._do_without_validation = _silent_dispatch
|
|
|
|
ws_client = lark.ws.Client(
|
|
self.config.app_id,
|
|
self.config.app_secret,
|
|
event_handler=handler,
|
|
log_level=lark.LogLevel.WARNING,
|
|
)
|
|
|
|
def _run_ws():
|
|
# Root cause: lark_oapi.ws.client stores the event loop in a
|
|
# *module-level* variable at import time. When imported from
|
|
# the main thread this captures the main loop (which has
|
|
# nest_asyncio patches). The SDK then calls
|
|
# loop.run_until_complete() from THIS thread on that *main*
|
|
# loop, causing cross-thread task-tracking conflicts:
|
|
# RuntimeError: Leaving task … does not match the current task
|
|
# AttributeError: 'NoneType' object has no attribute 'select'
|
|
#
|
|
# Fix: create a fresh event loop for this thread and replace
|
|
# the module-level ``loop`` variable so the SDK uses an
|
|
# isolated loop with no cross-thread interaction.
|
|
import lark_oapi.ws.client as _ws_mod
|
|
|
|
fresh_loop = asyncio.new_event_loop()
|
|
asyncio.set_event_loop(fresh_loop)
|
|
_ws_mod.loop = fresh_loop
|
|
|
|
try:
|
|
ws_client.start()
|
|
except Exception:
|
|
logger.exception(
|
|
"Feishu WebSocket SDK thread exited unexpectedly. "
|
|
"The channel will no longer receive messages. "
|
|
"Check app_id/app_secret and connection limits."
|
|
)
|
|
|
|
self._lark_ws_thread = threading.Thread(target=_run_ws, daemon=True)
|
|
self._lark_ws_thread.start()
|
|
|
|
logger.info("Feishu channel started (WebSocket long connection mode)")
|
|
|
|
def _on_lark_sdk_message(self, data) -> None:
|
|
"""Sync callback invoked in lark-oapi SDK thread.
|
|
|
|
Converts the SDK event object to a dict and puts it on a
|
|
thread-safe queue. The ``_consume_ws_events`` task on the main
|
|
asyncio loop picks it up — no asyncio cross-thread calls needed,
|
|
avoiding the nest_asyncio + Python 3.11 contextvars conflict.
|
|
"""
|
|
try:
|
|
event = data.event
|
|
msg = event.message
|
|
sender = event.sender
|
|
|
|
# Rebuild mentions list from SDK objects
|
|
mentions_list = []
|
|
if msg.mentions:
|
|
for m in msg.mentions:
|
|
mention_dict: dict[str, Any] = {"key": m.key, "id": {}}
|
|
if m.id:
|
|
mention_dict["id"] = {
|
|
"open_id": getattr(m.id, "open_id", ""),
|
|
"user_id": getattr(m.id, "user_id", ""),
|
|
}
|
|
mentions_list.append(mention_dict)
|
|
|
|
event_dict = {
|
|
"sender": {
|
|
"sender_id": {
|
|
"open_id": sender.sender_id.open_id if sender.sender_id else "",
|
|
"user_id": getattr(sender.sender_id, "user_id", "")
|
|
if sender.sender_id
|
|
else "",
|
|
},
|
|
"sender_type": sender.sender_type or "",
|
|
},
|
|
"message": {
|
|
"chat_id": msg.chat_id or "",
|
|
"message_type": msg.message_type or "",
|
|
"message_id": msg.message_id or "",
|
|
"chat_type": msg.chat_type or "",
|
|
"content": msg.content or "{}",
|
|
"create_time": msg.create_time or "",
|
|
"mentions": mentions_list,
|
|
},
|
|
}
|
|
|
|
self._ws_event_queue.put(event_dict)
|
|
except Exception:
|
|
logger.exception("Feishu SDK message handler error")
|
|
|
|
async def _consume_ws_events(self) -> None:
|
|
"""Main-loop task that drains the thread-safe event queue."""
|
|
while self._running:
|
|
try:
|
|
event_dict = self._ws_event_queue.get_nowait()
|
|
try:
|
|
await self._on_message(event_dict)
|
|
except Exception:
|
|
logger.exception("Feishu WS event processing error")
|
|
except queue.Empty:
|
|
await asyncio.sleep(0.05)
|
|
|
|
async def _cleanup(self) -> None:
|
|
if self.config.subscription_mode == "websocket":
|
|
if self._ws_consumer_task:
|
|
self._ws_consumer_task.cancel()
|
|
try:
|
|
await self._ws_consumer_task
|
|
except asyncio.CancelledError:
|
|
pass
|
|
self._ws_consumer_task = None
|
|
if self._http_client:
|
|
await self._http_client.aclose()
|
|
self._http_client = None
|
|
# Daemon thread exits with the process; no explicit stop needed
|
|
self._lark_ws_thread = None
|
|
self._main_loop = None
|
|
self._ws_event_queue = None
|
|
else:
|
|
await self._stop_webhook_server()
|
|
self._access_token = None
|
|
logger.info("Feishu channel stopped")
|
|
|
|
# ── Token helpers (adapt old API to mixin) ────────────────────
|
|
|
|
async def _ensure_token(self) -> str:
|
|
"""Return a valid access token, refreshing if needed."""
|
|
return await TokenMixin._ensure_token(self)
|
|
|
|
# ── Send (template method overrides) ──────────────────────────
|
|
|
|
async def _feishu_send(self, url: str, body: dict, headers: dict) -> bool:
|
|
"""POST to Feishu API and return True if code==0."""
|
|
try:
|
|
resp = await self._http_client.post(url, json=body, headers=headers)
|
|
return resp.json().get("code") == 0
|
|
except Exception as e:
|
|
logger.warning(f"Feishu send error: {e}")
|
|
return False
|
|
|
|
async def _send_chunk(
|
|
self,
|
|
chat_id,
|
|
formatted_text,
|
|
raw_text,
|
|
reply_to,
|
|
metadata,
|
|
):
|
|
token = await self._ensure_token()
|
|
headers = {"Authorization": f"Bearer {token}"}
|
|
post_content = _markdown_to_feishu_post(raw_text)
|
|
|
|
# If reply_to is set, try the reply API first
|
|
if reply_to:
|
|
reply_url = (
|
|
f"{self.config.feishu_domain}/open-apis/im/v1/messages/{reply_to}/reply"
|
|
)
|
|
if post_content is not None:
|
|
body = {"msg_type": "post", "content": json.dumps(post_content)}
|
|
else:
|
|
body = {
|
|
"msg_type": "text",
|
|
"content": json.dumps({"text": formatted_text}),
|
|
}
|
|
if await self._feishu_send(reply_url, body, headers):
|
|
return
|
|
|
|
# Normal send (non-reply or reply fallback)
|
|
url = (
|
|
f"{self.config.feishu_domain}"
|
|
f"/open-apis/im/v1/messages?receive_id_type=chat_id"
|
|
)
|
|
|
|
# Try post format first
|
|
if post_content is not None:
|
|
body = {
|
|
"receive_id": chat_id,
|
|
"msg_type": "post",
|
|
"content": json.dumps(post_content),
|
|
}
|
|
if await self._feishu_send(url, body, headers):
|
|
return
|
|
|
|
# Fallback: plain text
|
|
body = {
|
|
"receive_id": chat_id,
|
|
"msg_type": "text",
|
|
"content": json.dumps({"text": formatted_text}),
|
|
}
|
|
if not await self._feishu_send(url, body, headers):
|
|
raise RuntimeError("Feishu send failed")
|
|
|
|
# ── Media helpers ──────────────────────────────────────────────
|
|
|
|
_IMAGE_EXTENSIONS: ClassVar[set[str]] = {
|
|
".jpg",
|
|
".jpeg",
|
|
".png",
|
|
".gif",
|
|
".bmp",
|
|
".webp",
|
|
}
|
|
|
|
async def _download_media(
|
|
self,
|
|
message_id: str,
|
|
file_key: str,
|
|
msg_type: str,
|
|
) -> str | None:
|
|
"""Download an image or file attachment from Feishu.
|
|
|
|
Returns the local file path on success, or None on failure.
|
|
"""
|
|
token = await self._ensure_token()
|
|
resource_type = "image" if msg_type == "image" else "file"
|
|
url = (
|
|
f"{self.config.feishu_domain}"
|
|
f"/open-apis/im/v1/messages/{message_id}"
|
|
f"/resources/{file_key}?type={resource_type}"
|
|
)
|
|
headers = {"Authorization": f"Bearer {token}"}
|
|
try:
|
|
resp = await self._http_client.get(url, headers=headers, timeout=30)
|
|
if resp.status_code != 200:
|
|
logger.warning(f"Feishu media download failed: HTTP {resp.status_code}")
|
|
return None
|
|
|
|
# Check attachment size before writing to disk
|
|
cl = resp.headers.get("content-length")
|
|
if cl:
|
|
try:
|
|
too_large = self._check_attachment_size(int(cl), file_key)
|
|
if too_large:
|
|
logger.warning(too_large)
|
|
return None
|
|
except (ValueError, TypeError):
|
|
pass
|
|
from ..base import MAX_ATTACHMENT_BYTES
|
|
|
|
if len(resp.content) > MAX_ATTACHMENT_BYTES:
|
|
logger.warning(f"Feishu media too large: {len(resp.content)} bytes")
|
|
return None
|
|
|
|
# Determine extension from Content-Type or default
|
|
content_type = resp.headers.get("content-type", "")
|
|
ext_map = {
|
|
"image/jpeg": ".jpg",
|
|
"image/png": ".png",
|
|
"image/gif": ".gif",
|
|
"image/webp": ".webp",
|
|
"image/bmp": ".bmp",
|
|
}
|
|
ext = ext_map.get(content_type, ".bin")
|
|
local_path = self._media_path(f"feishu_{message_id}_{file_key}{ext}")
|
|
local_path.write_bytes(resp.content)
|
|
return str(local_path)
|
|
except Exception as e:
|
|
logger.warning(f"Failed to download Feishu media: {e}")
|
|
return None
|
|
|
|
async def _upload_feishu_resource(
|
|
self,
|
|
url: str,
|
|
headers: dict,
|
|
file_path: str,
|
|
field_name: str,
|
|
extra_data: dict,
|
|
) -> dict | None:
|
|
"""Upload a file to Feishu API. Returns response data or None on failure."""
|
|
with open(file_path, "rb") as f:
|
|
resp = await self._http_client.post(
|
|
url,
|
|
headers=headers,
|
|
data=extra_data,
|
|
files={field_name: (Path(file_path).name, f)},
|
|
)
|
|
data = resp.json()
|
|
if data.get("code") != 0:
|
|
logger.error(f"Feishu upload failed: {data.get('msg')}")
|
|
return None
|
|
return data["data"]
|
|
|
|
async def _send_media_impl(
|
|
self,
|
|
recipient: str,
|
|
file_path: str,
|
|
caption: str = "",
|
|
metadata: dict | None = None,
|
|
) -> bool:
|
|
"""Send a media file through Feishu."""
|
|
token = await self._ensure_token()
|
|
headers = {"Authorization": f"Bearer {token}"}
|
|
chat_id = self._resolve_media_chat_id(recipient, metadata)
|
|
|
|
path = Path(file_path)
|
|
ext = path.suffix.lower()
|
|
is_image = ext in self._IMAGE_EXTENSIONS
|
|
|
|
send_url = (
|
|
f"{self.config.feishu_domain}"
|
|
f"/open-apis/im/v1/messages?receive_id_type=chat_id"
|
|
)
|
|
|
|
if is_image:
|
|
upload_url = f"{self.config.feishu_domain}/open-apis/im/v1/images"
|
|
data = await self._upload_feishu_resource(
|
|
upload_url,
|
|
headers,
|
|
file_path,
|
|
"image",
|
|
{"image_type": "message"},
|
|
)
|
|
if not data:
|
|
return False
|
|
body = {
|
|
"receive_id": chat_id,
|
|
"msg_type": "image",
|
|
"content": json.dumps({"image_key": data["image_key"]}),
|
|
}
|
|
else:
|
|
upload_url = f"{self.config.feishu_domain}/open-apis/im/v1/files"
|
|
data = await self._upload_feishu_resource(
|
|
upload_url,
|
|
headers,
|
|
file_path,
|
|
"file",
|
|
{"file_type": "stream", "file_name": path.name},
|
|
)
|
|
if not data:
|
|
return False
|
|
body = {
|
|
"receive_id": chat_id,
|
|
"msg_type": "file",
|
|
"content": json.dumps({"file_key": data["file_key"]}),
|
|
}
|
|
|
|
if not await self._feishu_send(send_url, body, headers):
|
|
return False
|
|
|
|
# Send caption as a separate text message if provided
|
|
if caption:
|
|
cap_body = {
|
|
"receive_id": chat_id,
|
|
"msg_type": "text",
|
|
"content": json.dumps({"text": caption}),
|
|
}
|
|
await self._feishu_send(send_url, cap_body, headers)
|
|
|
|
return True
|
|
|
|
# ── ACK reaction ───────────────────────────────────────────────
|
|
|
|
async def _send_ack_reaction(
|
|
self, chat_id: str, message_id: str, emoji: str = "THUMBSUP"
|
|
) -> None:
|
|
"""Send an acknowledgment reaction via Feishu Open API."""
|
|
try:
|
|
token = await self._ensure_token()
|
|
url = f"{self.config.feishu_domain}/open-apis/im/v1/messages/{message_id}/reactions"
|
|
await self._http_client.post(
|
|
url,
|
|
json={"reaction_type": {"emoji_type": emoji}},
|
|
headers={"Authorization": f"Bearer {token}"},
|
|
)
|
|
except Exception as e:
|
|
logger.debug(f"Feishu ack reaction failed: {e}")
|
|
|
|
async def _remove_ack_reaction(
|
|
self, chat_id: str, message_id: str, emoji: str = "THUMBSUP"
|
|
) -> None:
|
|
"""Remove ACK reaction via Feishu Open API.
|
|
|
|
Feishu's DELETE /reactions endpoint requires the reaction_id, which
|
|
we don't track. No-op for now.
|
|
"""
|
|
pass
|
|
|
|
# ── Mention stripping ─────────────────────────────────────────
|
|
|
|
def _strip_mention(self, text: str) -> str:
|
|
"""Strip bot @mention placeholders from Feishu text.
|
|
|
|
In Feishu v2 events the text contains placeholders like ``@_user_1``
|
|
for each mention. ``_mention_names`` caches the placeholder keys that
|
|
belong to the bot (identified during ``_on_message``).
|
|
"""
|
|
result = text
|
|
for key in self._mention_names:
|
|
result = result.replace(key, "")
|
|
# Clean up extra whitespace left behind
|
|
return re.sub(r" +", " ", result).strip()
|
|
|
|
# ── Event decryption ─────────────────────────────────────────
|
|
|
|
def _decrypt_event(self, encrypted: str) -> dict:
|
|
"""Decrypt a Feishu encrypted event payload (AES-256-CBC).
|
|
|
|
Feishu encryption spec:
|
|
key = SHA256(encrypt_key)
|
|
data = base64_decode(encrypted)
|
|
iv = data[:16]
|
|
plain = AES_CBC_decrypt(data[16:], key, iv) # PKCS7 padded
|
|
"""
|
|
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
|
|
|
|
key = hashlib.sha256(self.config.encrypt_key.encode()).digest()
|
|
data = base64.b64decode(encrypted)
|
|
iv, ciphertext = data[:16], data[16:]
|
|
cipher = Cipher(algorithms.AES(key), modes.CBC(iv))
|
|
decryptor = cipher.decryptor()
|
|
padded = decryptor.update(ciphertext) + decryptor.finalize()
|
|
# Remove PKCS7 padding
|
|
pad_len = padded[-1]
|
|
plaintext = padded[:-pad_len].decode()
|
|
return json.loads(plaintext)
|
|
|
|
# ── Webhook event handler ─────────────────────────────────────
|
|
|
|
async def _handle_event(self, request) -> web.Response:
|
|
"""Handle POST /webhook/event from Feishu."""
|
|
from aiohttp import web
|
|
|
|
try:
|
|
body = await request.json()
|
|
except Exception:
|
|
return web.Response(status=400)
|
|
|
|
# When encryption is configured the inbound POST MUST carry an
|
|
# ``encrypt`` field. A plaintext body used to skip decryption and
|
|
# reach the agent directly, defeating the encryption setup (issue
|
|
# #392). Treat a missing ``encrypt`` field on an
|
|
# encryption-configured channel as an authentication failure.
|
|
if self.config.encrypt_key:
|
|
if not isinstance(body, dict) or "encrypt" not in body:
|
|
logger.warning(
|
|
"Feishu event rejected: encrypt_key is configured but the "
|
|
"body has no 'encrypt' field (possible signature bypass)"
|
|
)
|
|
return web.Response(status=403)
|
|
try:
|
|
body = self._decrypt_event(body["encrypt"])
|
|
except Exception:
|
|
logger.exception("Feishu event decryption failed")
|
|
return web.Response(status=400)
|
|
|
|
# ── URL verification challenge ──
|
|
if body.get("type") == "url_verification":
|
|
challenge = body.get("challenge", "")
|
|
return web.json_response({"challenge": challenge})
|
|
|
|
# ── v2 event schema ──
|
|
schema = body.get("schema")
|
|
if schema == "2.0":
|
|
header = body.get("header", {})
|
|
|
|
# Verify token if configured
|
|
if self.config.verification_token:
|
|
token = header.get("token", "")
|
|
if token != self.config.verification_token:
|
|
logger.warning("Feishu event token mismatch")
|
|
return web.Response(status=403)
|
|
|
|
event_type = header.get("event_type", "")
|
|
logger.info(f"Feishu v2 event received: {event_type}")
|
|
if event_type == "im.message.receive_v1":
|
|
try:
|
|
await self._on_message(body.get("event", {}))
|
|
except Exception:
|
|
logger.exception("Feishu _on_message failed")
|
|
|
|
# ── v1 event schema (legacy) ──
|
|
elif "event" in body:
|
|
if self.config.verification_token:
|
|
token = body.get("token", "")
|
|
if token != self.config.verification_token:
|
|
logger.warning("Feishu event token mismatch (v1)")
|
|
return web.Response(status=403)
|
|
|
|
event = body["event"]
|
|
msg_type = event.get("type", "")
|
|
logger.info(f"Feishu v1 event received: type={msg_type}")
|
|
if msg_type == "message":
|
|
try:
|
|
await self._on_message_v1(event)
|
|
except Exception:
|
|
logger.exception("Feishu _on_message_v1 failed")
|
|
else:
|
|
logger.info(f"Feishu event ignored: schema={schema}")
|
|
|
|
return web.Response(status=200)
|
|
|
|
async def _on_message(self, event: dict) -> None:
|
|
"""Handle im.message.receive_v1 event (v2 schema)."""
|
|
sender_info = event.get("sender", {})
|
|
sender_id_info = sender_info.get("sender_id", {})
|
|
sender_id = sender_id_info.get("open_id") or sender_id_info.get("user_id") or ""
|
|
sender_type = sender_info.get("sender_type", "")
|
|
|
|
# Skip bot's own messages
|
|
if sender_type == "app":
|
|
return
|
|
|
|
message = event.get("message", {})
|
|
chat_id = message.get("chat_id", "")
|
|
msg_type = message.get("message_type", "")
|
|
message_id = message.get("message_id", "")
|
|
|
|
# In group chats, detect mention status for centralized gating
|
|
chat_type = message.get("chat_type", "")
|
|
is_group = chat_type == "group"
|
|
was_mentioned = True
|
|
if is_group:
|
|
mentions = message.get("mentions", [])
|
|
was_mentioned = bool(mentions)
|
|
# Cache bot mention keys — bot mentions have empty user IDs
|
|
bot_keys = []
|
|
for m in mentions:
|
|
m_id = m.get("id", {})
|
|
# Bot/app mentions have no open_id / user_id
|
|
if not m_id.get("open_id") and not m_id.get("user_id"):
|
|
key = m.get("key", "")
|
|
if key:
|
|
bot_keys.append(key)
|
|
if bot_keys:
|
|
self._mention_names = bot_keys
|
|
|
|
# Parse content JSON
|
|
content_str = message.get("content", "{}")
|
|
try:
|
|
content_data = json.loads(content_str)
|
|
except json.JSONDecodeError:
|
|
content_data = {}
|
|
|
|
text = ""
|
|
annotations: list[str] = []
|
|
media_paths: list[str] = []
|
|
|
|
if msg_type == "text":
|
|
text = content_data.get("text", "")
|
|
elif msg_type == "post":
|
|
text = self._extract_post_text(content_data)
|
|
elif msg_type == "image" and self.config.include_attachments:
|
|
image_key = content_data.get("image_key", "")
|
|
if image_key:
|
|
local = await self._download_media(message_id, image_key, "image")
|
|
if local:
|
|
media_paths.append(local)
|
|
annotations.append(f"[attachment: {local}]")
|
|
else:
|
|
annotations.append("[image message - download failed]")
|
|
else:
|
|
annotations.append("[image message]")
|
|
elif msg_type == "file" and self.config.include_attachments:
|
|
file_key = content_data.get("file_key", "")
|
|
file_name = content_data.get("file_name", "unknown")
|
|
if file_key:
|
|
local = await self._download_media(message_id, file_key, "file")
|
|
if local:
|
|
media_paths.append(local)
|
|
annotations.append(f"[attachment: {local}]")
|
|
else:
|
|
annotations.append(f"[file: {file_name} - download failed]")
|
|
else:
|
|
annotations.append(f"[file message: {file_name}]")
|
|
elif msg_type in ("audio", "media") and self.config.include_attachments:
|
|
# Feishu audio messages are voice recordings
|
|
media_label = "voice" if msg_type == "audio" else msg_type
|
|
file_key = content_data.get("file_key", "")
|
|
if file_key:
|
|
local = await self._download_media(message_id, file_key, "file")
|
|
if local:
|
|
media_paths.append(local)
|
|
annotations.append(f"[{media_label}: {local}]")
|
|
else:
|
|
annotations.append(f"[{media_label} message - download failed]")
|
|
else:
|
|
annotations.append(f"[{media_label} message]")
|
|
elif msg_type == "sticker":
|
|
sticker_key = content_data.get("file_key", "")
|
|
if sticker_key and self.config.include_attachments:
|
|
local = await self._download_media(message_id, sticker_key, "image")
|
|
if local:
|
|
media_paths.append(local)
|
|
annotations.append(f"[sticker: {local}]")
|
|
else:
|
|
annotations.append("[sticker message]")
|
|
else:
|
|
annotations.append("[sticker message]")
|
|
else:
|
|
text = f"[{msg_type} message]"
|
|
|
|
if not text and not media_paths and not annotations:
|
|
return
|
|
|
|
# Parse timestamp (milliseconds)
|
|
create_time = message.get("create_time", "")
|
|
try:
|
|
timestamp = (
|
|
datetime.fromtimestamp(int(create_time) / 1000)
|
|
if create_time
|
|
else datetime.now()
|
|
)
|
|
except (ValueError, TypeError, OSError):
|
|
timestamp = datetime.now()
|
|
|
|
await self._enqueue_raw(
|
|
RawIncoming(
|
|
sender_id=sender_id,
|
|
chat_id=chat_id,
|
|
text=text,
|
|
media_files=media_paths,
|
|
content_annotations=annotations,
|
|
timestamp=timestamp,
|
|
message_id=message_id,
|
|
metadata={
|
|
"chat_id": chat_id,
|
|
"chat_type": message.get("chat_type", ""),
|
|
},
|
|
is_group=is_group,
|
|
was_mentioned=was_mentioned,
|
|
)
|
|
)
|
|
|
|
async def _on_message_v1(self, event: dict) -> None:
|
|
"""Handle v1 schema message event (legacy)."""
|
|
sender_id = event.get("open_id", "")
|
|
if not sender_id:
|
|
return
|
|
|
|
# Detect group and mention status for centralized gating
|
|
chat_type = event.get("chat_type", "")
|
|
is_group = chat_type == "group"
|
|
was_mentioned = True
|
|
if is_group:
|
|
text_without_at = event.get("text_without_at_bot", "")
|
|
was_mentioned = bool(text_without_at)
|
|
|
|
text = event.get("text_without_at_bot", "") or event.get("text", "")
|
|
if not text:
|
|
return
|
|
|
|
chat_id = event.get("open_chat_id", "")
|
|
message_id = event.get("open_message_id", "")
|
|
|
|
await self._enqueue_raw(
|
|
RawIncoming(
|
|
sender_id=sender_id,
|
|
chat_id=chat_id,
|
|
text=text,
|
|
timestamp=datetime.now(),
|
|
message_id=message_id,
|
|
metadata={
|
|
"chat_id": chat_id,
|
|
"chat_type": event.get("chat_type", ""),
|
|
},
|
|
is_group=is_group,
|
|
was_mentioned=was_mentioned,
|
|
)
|
|
)
|
|
|
|
@staticmethod
|
|
def _extract_post_text(content: dict) -> str:
|
|
"""Extract plain text from Feishu post (rich text) content."""
|
|
parts: list[str] = []
|
|
# Post content has locale keys like "zh_cn", "en_us"
|
|
for locale_key in ("zh_cn", "en_us", "ja_jp"):
|
|
locale_content = content.get(locale_key)
|
|
if locale_content:
|
|
title = locale_content.get("title", "")
|
|
if title:
|
|
parts.append(title)
|
|
for paragraph in locale_content.get("content", []):
|
|
line_parts: list[str] = []
|
|
for element in paragraph:
|
|
tag = element.get("tag", "")
|
|
if tag == "text":
|
|
line_parts.append(element.get("text", ""))
|
|
elif tag == "a":
|
|
line_parts.append(element.get("text", ""))
|
|
elif tag == "at":
|
|
# Skip @mentions of the bot
|
|
pass
|
|
line = "".join(line_parts).strip()
|
|
if line:
|
|
parts.append(line)
|
|
break # Use first available locale
|
|
return "\n".join(parts)
|