fix feishu bugs

This commit is contained in:
MuXinCG
2026-02-17 16:08:41 +08:00
parent bec3fc0b67
commit 5fd8e1a8ad
3 changed files with 351 additions and 7 deletions
+46 -2
View File
@@ -18,6 +18,8 @@ Send API:
from __future__ import annotations
import base64
import hashlib
import json
import logging
import re
@@ -538,6 +540,30 @@ class FeishuChannel(Channel, WebhookMixin, TokenMixin):
# 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":
@@ -549,6 +575,14 @@ class FeishuChannel(Channel, WebhookMixin, TokenMixin):
except Exception:
return web.Response(status=400)
# ── Decrypt if encrypt_key is configured ──
if self.config.encrypt_key and "encrypt" in body:
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", "")
@@ -567,8 +601,12 @@ class FeishuChannel(Channel, WebhookMixin, TokenMixin):
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":
await self._on_message(body.get("event", {}))
try:
await self._on_message(body.get("event", {}))
except Exception:
logger.exception("Feishu _on_message failed")
# ── v1 event schema (legacy) ──
elif "event" in body:
@@ -580,8 +618,14 @@ class FeishuChannel(Channel, WebhookMixin, TokenMixin):
event = body["event"]
msg_type = event.get("type", "")
logger.info(f"Feishu v1 event received: type={msg_type}")
if msg_type == "message":
await self._on_message_v1(event)
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)
+1 -1
View File
@@ -2,7 +2,7 @@
import asyncio
import json
from unittest.mock import AsyncMock, MagicMock, patch
from unittest.mock import AsyncMock, MagicMock
import pytest
+304 -4
View File
@@ -2,6 +2,7 @@
import asyncio
import json
from unittest.mock import AsyncMock, MagicMock
import pytest
@@ -10,8 +11,9 @@ from EvoScientist.channels.feishu.channel import (
FeishuConfig,
_markdown_to_feishu_post,
_parse_inline_text,
_parse_inline_elements,
)
from EvoScientist.channels.base import ChannelError
from EvoScientist.channels.base import ChannelError, OutboundMessage
def _run(coro):
@@ -80,8 +82,6 @@ class TestFeishuChannel:
_run(channel.stop())
def test_send_returns_false_without_client(self):
from EvoScientist.channels.base import OutboundMessage
config = FeishuConfig(app_id="test-id", app_secret="test-secret")
channel = FeishuChannel(config)
msg = OutboundMessage(
@@ -126,6 +126,17 @@ class TestFeishuChannel:
result = FeishuChannel._extract_post_text({})
assert result == ""
def test_extract_post_text_skips_at_mentions(self):
content = {
"zh_cn": {
"content": [
[{"tag": "text", "text": "Hi "}, {"tag": "at", "user_id": "bot"}],
],
}
}
result = FeishuChannel._extract_post_text(content)
assert result == "Hi"
def test_strip_mention(self):
config = FeishuConfig()
channel = FeishuChannel(config)
@@ -133,6 +144,236 @@ class TestFeishuChannel:
result = channel._strip_mention("@_user_1 hello world")
assert result == "hello world"
def test_strip_mention_multiple(self):
config = FeishuConfig()
channel = FeishuChannel(config)
channel._mention_names = ["@_user_1", "@_user_2"]
result = channel._strip_mention("@_user_1 @_user_2 hello")
assert result == "hello"
def test_strip_mention_no_match(self):
config = FeishuConfig()
channel = FeishuChannel(config)
channel._mention_names = []
result = channel._strip_mention("hello world")
assert result == "hello world"
class TestFeishuErrorPatterns:
"""Test non-retryable and rate-limit pattern detection."""
def test_non_retryable_patterns_defined(self):
config = FeishuConfig()
channel = FeishuChannel(config)
assert len(channel._non_retryable_patterns) > 0
assert "10003" in channel._non_retryable_patterns
assert "99991401" in channel._non_retryable_patterns
def test_non_retryable_returns_none(self):
config = FeishuConfig()
channel = FeishuChannel(config)
exc = Exception("error code 10003: invalid app_id")
result = channel._extract_retry_after(exc)
assert result is None
def test_rate_limit_patterns_defined(self):
config = FeishuConfig()
channel = FeishuChannel(config)
assert "99991400" in channel._rate_limit_patterns
assert "频率限制" in channel._rate_limit_patterns
def test_rate_limit_returns_delay(self):
config = FeishuConfig()
channel = FeishuChannel(config)
exc = Exception("99991400 频率限制")
result = channel._extract_retry_after(exc)
assert result is not None
assert result == channel._rate_limit_delay
class TestFeishuWebhookEvent:
"""Test _on_message parsing with mocked bus."""
def _make_channel(self):
config = FeishuConfig(app_id="test-app", app_secret="test-secret")
channel = FeishuChannel(config)
channel._running = True
channel._http_client = MagicMock()
channel._access_token = "fake-token"
channel._token_expires = 9999999999
channel._enqueue_raw = AsyncMock()
return channel
def test_text_message_v2(self):
channel = self._make_channel()
event = {
"sender": {
"sender_id": {"open_id": "ou_test123"},
"sender_type": "user",
},
"message": {
"chat_id": "oc_chat1",
"message_type": "text",
"message_id": "msg_1",
"chat_type": "p2p",
"content": json.dumps({"text": "hello feishu"}),
"create_time": "1700000000000",
},
}
_run(channel._on_message(event))
channel._enqueue_raw.assert_called_once()
raw = channel._enqueue_raw.call_args[0][0]
assert raw.text == "hello feishu"
assert raw.sender_id == "ou_test123"
assert raw.chat_id == "oc_chat1"
assert raw.is_group is False
def test_group_message_with_mention(self):
channel = self._make_channel()
event = {
"sender": {
"sender_id": {"open_id": "ou_sender"},
"sender_type": "user",
},
"message": {
"chat_id": "oc_group1",
"message_type": "text",
"message_id": "msg_2",
"chat_type": "group",
"content": json.dumps({"text": "@_user_1 do something"}),
"create_time": "1700000000000",
"mentions": [{"key": "@_user_1", "id": {}}],
},
}
_run(channel._on_message(event))
raw = channel._enqueue_raw.call_args[0][0]
assert raw.is_group is True
assert raw.was_mentioned is True
assert channel._mention_names == ["@_user_1"]
def test_group_message_no_mention(self):
channel = self._make_channel()
event = {
"sender": {
"sender_id": {"open_id": "ou_sender"},
"sender_type": "user",
},
"message": {
"chat_id": "oc_group2",
"message_type": "text",
"message_id": "msg_3",
"chat_type": "group",
"content": json.dumps({"text": "just talking"}),
"create_time": "1700000000000",
},
}
_run(channel._on_message(event))
raw = channel._enqueue_raw.call_args[0][0]
assert raw.is_group is True
assert raw.was_mentioned is False
def test_skips_bot_messages(self):
channel = self._make_channel()
event = {
"sender": {
"sender_id": {"open_id": "ou_bot"},
"sender_type": "app",
},
"message": {
"chat_id": "oc_chat",
"message_type": "text",
"message_id": "msg_bot",
"content": json.dumps({"text": "bot reply"}),
},
}
_run(channel._on_message(event))
channel._enqueue_raw.assert_not_called()
def test_post_message(self):
channel = self._make_channel()
post_content = {
"zh_cn": {
"title": "Test",
"content": [[{"tag": "text", "text": "Post body"}]],
}
}
event = {
"sender": {
"sender_id": {"open_id": "ou_test"},
"sender_type": "user",
},
"message": {
"chat_id": "oc_chat",
"message_type": "post",
"message_id": "msg_post",
"chat_type": "p2p",
"content": json.dumps(post_content),
"create_time": "1700000000000",
},
}
_run(channel._on_message(event))
raw = channel._enqueue_raw.call_args[0][0]
assert "Test" in raw.text
assert "Post body" in raw.text
def test_unsupported_msg_type_annotation(self):
channel = self._make_channel()
event = {
"sender": {
"sender_id": {"open_id": "ou_test"},
"sender_type": "user",
},
"message": {
"chat_id": "oc_chat",
"message_type": "share_chat",
"message_id": "msg_share",
"chat_type": "p2p",
"content": "{}",
"create_time": "1700000000000",
},
}
_run(channel._on_message(event))
raw = channel._enqueue_raw.call_args[0][0]
assert "share_chat" in raw.text
class TestFeishuSendChunk:
"""Test _send_chunk with mocked HTTP client."""
def test_send_chunk_post_format(self):
config = FeishuConfig(app_id="test-app", app_secret="test-secret")
channel = FeishuChannel(config)
channel._access_token = "fake-token"
channel._token_expires = 9999999999
mock_response = MagicMock()
mock_response.json.return_value = {"code": 0}
channel._http_client = MagicMock()
channel._http_client.post = AsyncMock(return_value=mock_response)
_run(channel._send_chunk("oc_chat1", "formatted", "raw **text**", None, {}))
channel._http_client.post.assert_called()
# Should try post format first
call_args = channel._http_client.post.call_args
body = call_args.kwargs.get("json") or call_args[1].get("json")
assert body["receive_id"] == "oc_chat1"
def test_send_chunk_with_reply(self):
config = FeishuConfig(app_id="test-app", app_secret="test-secret")
channel = FeishuChannel(config)
channel._access_token = "fake-token"
channel._token_expires = 9999999999
mock_response = MagicMock()
mock_response.json.return_value = {"code": 0}
channel._http_client = MagicMock()
channel._http_client.post = AsyncMock(return_value=mock_response)
_run(channel._send_chunk("oc_chat1", "reply", "reply text", "om_reply_id", {}))
# Should call the reply API endpoint
first_call_url = channel._http_client.post.call_args_list[0][0][0]
assert "reply" in first_call_url
class TestFeishuMarkdownConversion:
def test_empty_text(self):
@@ -151,7 +392,6 @@ class TestFeishuMarkdownConversion:
result = _markdown_to_feishu_post(md)
assert result is not None
content = result["zh_cn"]["content"]
# Should have a code_block element
found = False
for para in content:
for elem in para:
@@ -161,6 +401,16 @@ class TestFeishuMarkdownConversion:
assert "print" in elem["text"]
assert found
def test_code_block_no_language(self):
md = "```\nsome code\n```"
result = _markdown_to_feishu_post(md)
assert result is not None
content = result["zh_cn"]["content"]
for para in content:
for elem in para:
if elem.get("tag") == "code_block":
assert elem["language"] == "plain"
def test_bold_text(self):
elements = _parse_inline_text("**bold text**")
assert any(
@@ -182,6 +432,56 @@ class TestFeishuMarkdownConversion:
for e in elements
)
def test_strikethrough(self):
elements = _parse_inline_text("~~deleted~~")
assert any(
e.get("style") == ["strikethrough"] and e["text"] == "deleted"
for e in elements
)
def test_italic(self):
elements = _parse_inline_text("_italic text_")
assert any(
e.get("style") == ["italic"] and e["text"] == "italic text"
for e in elements
)
def test_heading(self):
elements = _parse_inline_elements("## My Heading")
assert any(
e.get("style") == ["bold"] and e["text"] == "My Heading"
for e in elements
)
def test_blockquote(self):
elements = _parse_inline_elements("> quoted text")
# Should have ▎ prefix with italic style
assert any(e.get("text") == "▎" for e in elements)
assert any(e.get("text") == "quoted text" for e in elements)
def test_unordered_list(self):
elements = _parse_inline_elements("- list item")
assert any(e.get("text") == "• " for e in elements)
assert any(e.get("text") == "list item" for e in elements)
def test_ordered_list(self):
elements = _parse_inline_elements("3. third item")
assert any(e.get("text") == "3. " for e in elements)
assert any(e.get("text") == "third item" for e in elements)
def test_multi_paragraph(self):
md = "First paragraph\n\nSecond paragraph"
result = _markdown_to_feishu_post(md)
content = result["zh_cn"]["content"]
assert len(content) == 2
def test_mixed_content(self):
md = "# Title\n\nSome **bold** text\n\n```python\ncode\n```"
result = _markdown_to_feishu_post(md)
assert result is not None
content = result["zh_cn"]["content"]
assert len(content) >= 3
class TestFeishuChannelRegistration:
def test_feishu_registered(self):