fix feishu bugs
This commit is contained in:
@@ -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)
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
Reference in New Issue
Block a user