Compare commits

...

7 Commits

Author SHA1 Message Date
Hua
61dcdffbbe Merge remote-tracking branch 'origin/main'
Some checks failed
Test Suite / test (3.11) (push) Failing after 1m7s
Test Suite / test (3.12) (push) Failing after 1m5s
Test Suite / test (3.13) (push) Failing after 1m7s
# Conflicts:
#	nanobot/channels/slack.py
#	nanobot/config/schema.py
#	tests/test_feishu_reply.py
2026-03-17 21:13:12 +08:00
Xubin Ren
7086f57d05 test(feishu): cover media msg_type mapping 2026-03-17 17:05:13 +08:00
weipeng0098
47e2a1e8d7 fix(feishu): use correct msg_type for audio/video files 2026-03-17 17:05:13 +08:00
Xubin Ren
41d59c3b89 test(feishu): cover heading and table markdown rendering 2026-03-17 16:51:02 +08:00
Your Name
9afbf386c4 fix(feishu): fix markdown rendering issues in headings and tables
- Fix double bold markers (****) when heading text already contains **
- Strip markdown formatting (**bold**, *italic*, ~~strike~~) from table cells
  since Feishu table elements do not support markdown rendering

Fixes rendering issues where:
1. Headings like '**text**' were rendered as '****text****'
2. Table cells with '**bold**' showed raw markdown instead of plain text
2026-03-17 16:51:02 +08:00
Xubin Ren
91ca82035a feat(slack): add default done reaction on completion 2026-03-17 16:19:08 +08:00
Sihyeon Jang
8aebe20cac feat(slack): update reaction emoji on task completion
Remove the in-progress reaction (reactEmoji) and optionally add a
done reaction (doneEmoji) when the final response is sent, so users
get visual feedback that processing has finished.

Signed-off-by: Sihyeon Jang <sihyeon.jang@navercorp.com>
2026-03-17 16:19:08 +08:00
6 changed files with 795 additions and 27 deletions

View File

@@ -424,16 +424,39 @@ class FeishuChannel(BaseChannel):
_CODE_BLOCK_RE = re.compile(r"(```[\s\S]*?```)", re.MULTILINE) _CODE_BLOCK_RE = re.compile(r"(```[\s\S]*?```)", re.MULTILINE)
@staticmethod # Markdown formatting patterns that should be stripped from plain-text
def _parse_md_table(table_text: str) -> dict | None: # surfaces like table cells and heading text.
_MD_BOLD_RE = re.compile(r"\*\*(.+?)\*\*")
_MD_BOLD_UNDERSCORE_RE = re.compile(r"__(.+?)__")
_MD_ITALIC_RE = re.compile(r"(?<!\*)\*(?!\*)(.+?)(?<!\*)\*(?!\*)")
_MD_STRIKE_RE = re.compile(r"~~(.+?)~~")
@classmethod
def _strip_md_formatting(cls, text: str) -> str:
"""Strip markdown formatting markers from text for plain display.
Feishu table cells do not support markdown rendering, so we remove
the formatting markers to keep the text readable.
"""
# Remove bold markers
text = cls._MD_BOLD_RE.sub(r"\1", text)
text = cls._MD_BOLD_UNDERSCORE_RE.sub(r"\1", text)
# Remove italic markers
text = cls._MD_ITALIC_RE.sub(r"\1", text)
# Remove strikethrough markers
text = cls._MD_STRIKE_RE.sub(r"\1", text)
return text
@classmethod
def _parse_md_table(cls, table_text: str) -> dict | None:
"""Parse a markdown table into a Feishu table element.""" """Parse a markdown table into a Feishu table element."""
lines = [_line.strip() for _line in table_text.strip().split("\n") if _line.strip()] lines = [_line.strip() for _line in table_text.strip().split("\n") if _line.strip()]
if len(lines) < 3: if len(lines) < 3:
return None return None
def split(_line: str) -> list[str]: def split(_line: str) -> list[str]:
return [c.strip() for c in _line.strip("|").split("|")] return [c.strip() for c in _line.strip("|").split("|")]
headers = split(lines[0]) headers = [cls._strip_md_formatting(h) for h in split(lines[0])]
rows = [split(_line) for _line in lines[2:]] rows = [[cls._strip_md_formatting(c) for c in split(_line)] for _line in lines[2:]]
columns = [{"tag": "column", "name": f"c{i}", "display_name": h, "width": "auto"} columns = [{"tag": "column", "name": f"c{i}", "display_name": h, "width": "auto"}
for i, h in enumerate(headers)] for i, h in enumerate(headers)]
return { return {
@@ -499,12 +522,13 @@ class FeishuChannel(BaseChannel):
before = protected[last_end:m.start()].strip() before = protected[last_end:m.start()].strip()
if before: if before:
elements.append({"tag": "markdown", "content": before}) elements.append({"tag": "markdown", "content": before})
text = m.group(2).strip() text = self._strip_md_formatting(m.group(2).strip())
display_text = f"**{text}**" if text else ""
elements.append({ elements.append({
"tag": "div", "tag": "div",
"text": { "text": {
"tag": "lark_md", "tag": "lark_md",
"content": f"**{text}**", "content": display_text,
}, },
}) })
last_end = m.end() last_end = m.end()
@@ -794,6 +818,76 @@ class FeishuChannel(BaseChannel):
return None, f"[{msg_type}: download failed]" return None, f"[{msg_type}: download failed]"
_REPLY_CONTEXT_MAX_LEN = 200
def _get_message_content_sync(self, message_id: str) -> str | None:
"""Fetch quoted text context for a parent Feishu message."""
from lark_oapi.api.im.v1 import GetMessageRequest
try:
request = GetMessageRequest.builder().message_id(message_id).build()
response = self._client.im.v1.message.get(request)
if not response.success():
logger.debug(
"Feishu: could not fetch parent message {}: code={}, msg={}",
message_id, response.code, response.msg,
)
return None
items = getattr(response.data, "items", None)
if not items:
return None
msg_obj = items[0]
raw_content = getattr(msg_obj, "body", None)
raw_content = getattr(raw_content, "content", None) if raw_content else None
if not raw_content:
return None
try:
content_json = json.loads(raw_content)
except (json.JSONDecodeError, TypeError):
return None
msg_type = getattr(msg_obj, "msg_type", "")
if msg_type == "text":
text = content_json.get("text", "").strip()
elif msg_type == "post":
text, _ = _extract_post_content(content_json)
text = text.strip()
else:
text = ""
if not text:
return None
if len(text) > self._REPLY_CONTEXT_MAX_LEN:
text = text[: self._REPLY_CONTEXT_MAX_LEN] + "..."
return f"[Reply to: {text}]"
except Exception as e:
logger.debug("Feishu: error fetching parent message {}: {}", message_id, e)
return None
def _reply_message_sync(self, parent_message_id: str, msg_type: str, content: str) -> bool:
"""Reply to an existing Feishu message using the Reply API."""
from lark_oapi.api.im.v1 import ReplyMessageRequest, ReplyMessageRequestBody
try:
request = ReplyMessageRequest.builder() \
.message_id(parent_message_id) \
.request_body(
ReplyMessageRequestBody.builder()
.msg_type(msg_type)
.content(content)
.build()
).build()
response = self._client.im.v1.message.reply(request)
if not response.success():
logger.error(
"Failed to reply to Feishu message {}: code={}, msg={}, log_id={}",
parent_message_id, response.code, response.msg, response.get_log_id(),
)
return False
logger.debug("Feishu reply sent to message {}", parent_message_id)
return True
except Exception as e:
logger.error("Error replying to Feishu message {}: {}", parent_message_id, e)
return False
def _send_message_sync(self, receive_id_type: str, receive_id: str, msg_type: str, content: str) -> bool: def _send_message_sync(self, receive_id_type: str, receive_id: str, msg_type: str, content: str) -> bool:
"""Send a single message (text/image/file/interactive) synchronously.""" """Send a single message (text/image/file/interactive) synchronously."""
from lark_oapi.api.im.v1 import CreateMessageRequest, CreateMessageRequestBody from lark_oapi.api.im.v1 import CreateMessageRequest, CreateMessageRequestBody
@@ -830,6 +924,27 @@ class FeishuChannel(BaseChannel):
receive_id_type = "chat_id" if msg.chat_id.startswith("oc_") else "open_id" receive_id_type = "chat_id" if msg.chat_id.startswith("oc_") else "open_id"
loop = asyncio.get_running_loop() loop = asyncio.get_running_loop()
if msg.metadata.get("_tool_hint"):
if msg.content and msg.content.strip():
await self._send_tool_hint_card(
receive_id_type, msg.chat_id, msg.content.strip(),
)
return
reply_message_id: str | None = None
if self.config.reply_to_message and not msg.metadata.get("_progress", False):
reply_message_id = msg.metadata.get("message_id") or None
first_send = True
def _do_send(m_type: str, content: str) -> None:
nonlocal first_send
if reply_message_id and first_send:
first_send = False
if self._reply_message_sync(reply_message_id, m_type, content):
return
self._send_message_sync(receive_id_type, msg.chat_id, m_type, content)
for file_path in msg.media: for file_path in msg.media:
if not os.path.isfile(file_path): if not os.path.isfile(file_path):
logger.warning("Media file not found: {}", file_path) logger.warning("Media file not found: {}", file_path)
@@ -839,21 +954,24 @@ class FeishuChannel(BaseChannel):
key = await loop.run_in_executor(None, self._upload_image_sync, file_path) key = await loop.run_in_executor(None, self._upload_image_sync, file_path)
if key: if key:
await loop.run_in_executor( await loop.run_in_executor(
None, self._send_message_sync, None, _do_send,
receive_id_type, msg.chat_id, "image", json.dumps({"image_key": key}, ensure_ascii=False), "image", json.dumps({"image_key": key}, ensure_ascii=False),
) )
else: else:
key = await loop.run_in_executor(None, self._upload_file_sync, file_path) key = await loop.run_in_executor(None, self._upload_file_sync, file_path)
if key: if key:
# Use msg_type "media" for audio/video so users can play inline; # Use msg_type "audio" for audio, "video" for video, "file" for documents.
# "file" for everything else (documents, archives, etc.) # Feishu requires these specific msg_types for inline playback.
if ext in self._AUDIO_EXTS or ext in self._VIDEO_EXTS: # Note: "media" is only valid as a tag inside "post" messages, not as a standalone msg_type.
media_type = "media" if ext in self._AUDIO_EXTS:
media_type = "audio"
elif ext in self._VIDEO_EXTS:
media_type = "video"
else: else:
media_type = "file" media_type = "file"
await loop.run_in_executor( await loop.run_in_executor(
None, self._send_message_sync, None, _do_send,
receive_id_type, msg.chat_id, media_type, json.dumps({"file_key": key}, ensure_ascii=False), media_type, json.dumps({"file_key": key}, ensure_ascii=False),
) )
if msg.content and msg.content.strip(): if msg.content and msg.content.strip():
@@ -862,18 +980,12 @@ class FeishuChannel(BaseChannel):
if fmt == "text": if fmt == "text":
# Short plain text send as simple text message # Short plain text send as simple text message
text_body = json.dumps({"text": msg.content.strip()}, ensure_ascii=False) text_body = json.dumps({"text": msg.content.strip()}, ensure_ascii=False)
await loop.run_in_executor( await loop.run_in_executor(None, _do_send, "text", text_body)
None, self._send_message_sync,
receive_id_type, msg.chat_id, "text", text_body,
)
elif fmt == "post": elif fmt == "post":
# Medium content with links send as rich-text post # Medium content with links send as rich-text post
post_body = self._markdown_to_post(msg.content) post_body = self._markdown_to_post(msg.content)
await loop.run_in_executor( await loop.run_in_executor(None, _do_send, "post", post_body)
None, self._send_message_sync,
receive_id_type, msg.chat_id, "post", post_body,
)
else: else:
# Complex / long content send as interactive card # Complex / long content send as interactive card
@@ -881,8 +993,8 @@ class FeishuChannel(BaseChannel):
for chunk in self._split_elements_by_table_limit(elements): for chunk in self._split_elements_by_table_limit(elements):
card = {"config": {"wide_screen_mode": True}, "elements": chunk} card = {"config": {"wide_screen_mode": True}, "elements": chunk}
await loop.run_in_executor( await loop.run_in_executor(
None, self._send_message_sync, None, _do_send,
receive_id_type, msg.chat_id, "interactive", json.dumps(card, ensure_ascii=False), "interactive", json.dumps(card, ensure_ascii=False),
) )
except Exception as e: except Exception as e:
@@ -977,6 +1089,15 @@ class FeishuChannel(BaseChannel):
else: else:
content_parts.append(MSG_TYPE_MAP.get(msg_type, f"[{msg_type}]")) content_parts.append(MSG_TYPE_MAP.get(msg_type, f"[{msg_type}]"))
parent_id = getattr(message, "parent_id", None) or None
root_id = getattr(message, "root_id", None) or None
if parent_id and self._client:
loop = asyncio.get_running_loop()
reply_ctx = await loop.run_in_executor(None, self._get_message_content_sync, parent_id)
if reply_ctx:
content_parts.insert(0, reply_ctx)
content = "\n".join(content_parts) if content_parts else "" content = "\n".join(content_parts) if content_parts else ""
if not content and not media_paths: if not content and not media_paths:
@@ -993,6 +1114,8 @@ class FeishuChannel(BaseChannel):
"message_id": message_id, "message_id": message_id,
"chat_type": chat_type, "chat_type": chat_type,
"msg_type": msg_type, "msg_type": msg_type,
"parent_id": parent_id,
"root_id": root_id,
} }
) )
@@ -1011,3 +1134,73 @@ class FeishuChannel(BaseChannel):
"""Ignore p2p-enter events when a user opens a bot chat.""" """Ignore p2p-enter events when a user opens a bot chat."""
logger.debug("Bot entered p2p chat (user opened chat window)") logger.debug("Bot entered p2p chat (user opened chat window)")
pass pass
@staticmethod
def _format_tool_hint_lines(tool_hint: str) -> str:
"""Split tool hints across lines on top-level call separators only."""
parts: list[str] = []
buf: list[str] = []
depth = 0
in_string = False
quote_char = ""
escaped = False
for i, ch in enumerate(tool_hint):
buf.append(ch)
if in_string:
if escaped:
escaped = False
elif ch == "\\":
escaped = True
elif ch == quote_char:
in_string = False
continue
if ch in {'"', "'"}:
in_string = True
quote_char = ch
continue
if ch == "(":
depth += 1
continue
if ch == ")" and depth > 0:
depth -= 1
continue
if ch == "," and depth == 0:
next_char = tool_hint[i + 1] if i + 1 < len(tool_hint) else ""
if next_char == " ":
parts.append("".join(buf).rstrip())
buf = []
if buf:
parts.append("".join(buf).strip())
return "\n".join(part for part in parts if part)
async def _send_tool_hint_card(self, receive_id_type: str, receive_id: str, tool_hint: str) -> None:
"""Send tool hint as an interactive card with a formatted code block."""
loop = asyncio.get_running_loop()
formatted_code = self._format_tool_hint_lines(tool_hint)
card = {
"config": {"wide_screen_mode": True},
"elements": [
{
"tag": "markdown",
"content": f"**Tool Calls**\n\n```text\n{formatted_code}\n```",
},
],
}
await loop.run_in_executor(
None,
self._send_message_sync,
receive_id_type,
receive_id,
"interactive",
json.dumps(card, ensure_ascii=False),
)

View File

@@ -106,6 +106,12 @@ class SlackChannel(BaseChannel):
) )
except Exception as e: except Exception as e:
logger.error("Failed to upload file {}: {}", media_path, e) logger.error("Failed to upload file {}: {}", media_path, e)
# Update reaction emoji when the final (non-progress) response is sent
if not (msg.metadata or {}).get("_progress"):
event = slack_meta.get("event", {})
await self._update_react_emoji(msg.chat_id, event.get("ts"))
except Exception as e: except Exception as e:
logger.error("Error sending Slack message: {}", e) logger.error("Error sending Slack message: {}", e)
@@ -203,6 +209,28 @@ class SlackChannel(BaseChannel):
except Exception: except Exception:
logger.exception("Error handling Slack message from {}", sender_id) logger.exception("Error handling Slack message from {}", sender_id)
async def _update_react_emoji(self, chat_id: str, ts: str | None) -> None:
"""Remove the in-progress reaction and optionally add a done reaction."""
if not self._web_client or not ts:
return
try:
await self._web_client.reactions_remove(
channel=chat_id,
name=self.config.react_emoji,
timestamp=ts,
)
except Exception as e:
logger.debug("Slack reactions_remove failed: {}", e)
if self.config.done_emoji:
try:
await self._web_client.reactions_add(
channel=chat_id,
name=self.config.done_emoji,
timestamp=ts,
)
except Exception as e:
logger.debug("Slack done reaction failed: {}", e)
def _is_allowed(self, sender_id: str, chat_id: str, channel_type: str) -> bool: def _is_allowed(self, sender_id: str, chat_id: str, channel_type: str) -> bool:
if channel_type == "im": if channel_type == "im":
if not self.config.dm.enabled: if not self.config.dm.enabled:

View File

@@ -13,7 +13,6 @@ class Base(BaseModel):
model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True) model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True)
class WhatsAppConfig(Base): class WhatsAppConfig(Base):
"""WhatsApp channel configuration.""" """WhatsApp channel configuration."""
@@ -75,6 +74,7 @@ class FeishuConfig(Base):
"THUMBSUP" # Emoji type for message reactions (e.g. THUMBSUP, OK, DONE, SMILE) "THUMBSUP" # Emoji type for message reactions (e.g. THUMBSUP, OK, DONE, SMILE)
) )
group_policy: Literal["open", "mention"] = "mention" # "mention" responds when @mentioned, "open" responds to all group_policy: Literal["open", "mention"] = "mention" # "mention" responds when @mentioned, "open" responds to all
reply_to_message: bool = False # If true, replies quote the original Feishu message
class FeishuInstanceConfig(FeishuConfig): class FeishuInstanceConfig(FeishuConfig):
@@ -288,6 +288,7 @@ class SlackConfig(Base):
user_token_read_only: bool = True user_token_read_only: bool = True
reply_in_thread: bool = True reply_in_thread: bool = True
react_emoji: str = "eyes" react_emoji: str = "eyes"
done_emoji: str = "white_check_mark"
allow_from: list[str] = Field(default_factory=list) # Allowed Slack user IDs (sender-level) allow_from: list[str] = Field(default_factory=list) # Allowed Slack user IDs (sender-level)
group_policy: str = "mention" # "mention", "open", "allowlist" group_policy: str = "mention" # "mention", "open", "allowlist"
group_allow_from: list[str] = Field(default_factory=list) # Allowed channel IDs if allowlist group_allow_from: list[str] = Field(default_factory=list) # Allowed channel IDs if allowlist
@@ -365,8 +366,6 @@ def _coerce_multi_channel_config(
if isinstance(value, dict) and "instances" in value: if isinstance(value, dict) and "instances" in value:
return multi_cls.model_validate(value) return multi_cls.model_validate(value)
return single_cls.model_validate(value) return single_cls.model_validate(value)
class ChannelsConfig(Base): class ChannelsConfig(Base):
"""Configuration for chat channels.""" """Configuration for chat channels."""

View File

@@ -0,0 +1,57 @@
from nanobot.channels.feishu import FeishuChannel
def test_parse_md_table_strips_markdown_formatting_in_headers_and_cells() -> None:
table = FeishuChannel._parse_md_table(
"""
| **Name** | __Status__ | *Notes* | ~~State~~ |
| --- | --- | --- | --- |
| **Alice** | __Ready__ | *Fast* | ~~Old~~ |
"""
)
assert table is not None
assert [col["display_name"] for col in table["columns"]] == [
"Name",
"Status",
"Notes",
"State",
]
assert table["rows"] == [
{"c0": "Alice", "c1": "Ready", "c2": "Fast", "c3": "Old"}
]
def test_split_headings_strips_embedded_markdown_before_bolding() -> None:
channel = FeishuChannel.__new__(FeishuChannel)
elements = channel._split_headings("# **Important** *status* ~~update~~")
assert elements == [
{
"tag": "div",
"text": {
"tag": "lark_md",
"content": "**Important status update**",
},
}
]
def test_split_headings_keeps_markdown_body_and_code_blocks_intact() -> None:
channel = FeishuChannel.__new__(FeishuChannel)
elements = channel._split_headings(
"# **Heading**\n\nBody with **bold** text.\n\n```python\nprint('hi')\n```"
)
assert elements[0] == {
"tag": "div",
"text": {
"tag": "lark_md",
"content": "**Heading**",
},
}
assert elements[1]["tag"] == "markdown"
assert "Body with **bold** text." in elements[1]["content"]
assert "```python\nprint('hi')\n```" in elements[1]["content"]

434
tests/test_feishu_reply.py Normal file
View File

@@ -0,0 +1,434 @@
"""Tests for Feishu message reply (quote) feature."""
import json
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import MagicMock, patch
import pytest
from nanobot.bus.events import OutboundMessage
from nanobot.bus.queue import MessageBus
from nanobot.channels.feishu import FeishuChannel, FeishuConfig
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _make_feishu_channel(reply_to_message: bool = False) -> FeishuChannel:
config = FeishuConfig(
enabled=True,
app_id="cli_test",
app_secret="secret",
allow_from=["*"],
reply_to_message=reply_to_message,
)
channel = FeishuChannel(config, MessageBus())
channel._client = MagicMock()
# _loop is only used by the WebSocket thread bridge; not needed for unit tests
channel._loop = None
return channel
def _make_feishu_event(
*,
message_id: str = "om_001",
chat_id: str = "oc_abc",
chat_type: str = "p2p",
msg_type: str = "text",
content: str = '{"text": "hello"}',
sender_open_id: str = "ou_alice",
parent_id: str | None = None,
root_id: str | None = None,
):
message = SimpleNamespace(
message_id=message_id,
chat_id=chat_id,
chat_type=chat_type,
message_type=msg_type,
content=content,
parent_id=parent_id,
root_id=root_id,
mentions=[],
)
sender = SimpleNamespace(
sender_type="user",
sender_id=SimpleNamespace(open_id=sender_open_id),
)
return SimpleNamespace(event=SimpleNamespace(message=message, sender=sender))
def _make_get_message_response(text: str, msg_type: str = "text", success: bool = True):
"""Build a fake im.v1.message.get response object."""
body = SimpleNamespace(content=json.dumps({"text": text}))
item = SimpleNamespace(msg_type=msg_type, body=body)
data = SimpleNamespace(items=[item])
resp = MagicMock()
resp.success.return_value = success
resp.data = data
resp.code = 0
resp.msg = "ok"
return resp
# ---------------------------------------------------------------------------
# Config tests
# ---------------------------------------------------------------------------
def test_feishu_config_reply_to_message_defaults_false() -> None:
assert FeishuConfig().reply_to_message is False
def test_feishu_config_reply_to_message_can_be_enabled() -> None:
config = FeishuConfig(reply_to_message=True)
assert config.reply_to_message is True
# ---------------------------------------------------------------------------
# _get_message_content_sync tests
# ---------------------------------------------------------------------------
def test_get_message_content_sync_returns_reply_prefix() -> None:
channel = _make_feishu_channel()
channel._client.im.v1.message.get.return_value = _make_get_message_response("what time is it?")
result = channel._get_message_content_sync("om_parent")
assert result == "[Reply to: what time is it?]"
def test_get_message_content_sync_truncates_long_text() -> None:
channel = _make_feishu_channel()
long_text = "x" * (FeishuChannel._REPLY_CONTEXT_MAX_LEN + 50)
channel._client.im.v1.message.get.return_value = _make_get_message_response(long_text)
result = channel._get_message_content_sync("om_parent")
assert result is not None
assert result.endswith("...]")
inner = result[len("[Reply to: ") : -1]
assert len(inner) == FeishuChannel._REPLY_CONTEXT_MAX_LEN + len("...")
def test_get_message_content_sync_returns_none_on_api_failure() -> None:
channel = _make_feishu_channel()
resp = MagicMock()
resp.success.return_value = False
resp.code = 230002
resp.msg = "bot not in group"
channel._client.im.v1.message.get.return_value = resp
result = channel._get_message_content_sync("om_parent")
assert result is None
def test_get_message_content_sync_returns_none_for_non_text_type() -> None:
channel = _make_feishu_channel()
body = SimpleNamespace(content=json.dumps({"image_key": "img_1"}))
item = SimpleNamespace(msg_type="image", body=body)
data = SimpleNamespace(items=[item])
resp = MagicMock()
resp.success.return_value = True
resp.data = data
channel._client.im.v1.message.get.return_value = resp
result = channel._get_message_content_sync("om_parent")
assert result is None
def test_get_message_content_sync_returns_none_when_empty_text() -> None:
channel = _make_feishu_channel()
channel._client.im.v1.message.get.return_value = _make_get_message_response(" ")
result = channel._get_message_content_sync("om_parent")
assert result is None
# ---------------------------------------------------------------------------
# _reply_message_sync tests
# ---------------------------------------------------------------------------
def test_reply_message_sync_returns_true_on_success() -> None:
channel = _make_feishu_channel()
resp = MagicMock()
resp.success.return_value = True
channel._client.im.v1.message.reply.return_value = resp
ok = channel._reply_message_sync("om_parent", "text", '{"text":"hi"}')
assert ok is True
channel._client.im.v1.message.reply.assert_called_once()
def test_reply_message_sync_returns_false_on_api_error() -> None:
channel = _make_feishu_channel()
resp = MagicMock()
resp.success.return_value = False
resp.code = 400
resp.msg = "bad request"
resp.get_log_id.return_value = "log_x"
channel._client.im.v1.message.reply.return_value = resp
ok = channel._reply_message_sync("om_parent", "text", '{"text":"hi"}')
assert ok is False
def test_reply_message_sync_returns_false_on_exception() -> None:
channel = _make_feishu_channel()
channel._client.im.v1.message.reply.side_effect = RuntimeError("network error")
ok = channel._reply_message_sync("om_parent", "text", '{"text":"hi"}')
assert ok is False
@pytest.mark.asyncio
@pytest.mark.parametrize(
("filename", "expected_msg_type"),
[
("voice.opus", "audio"),
("clip.mp4", "video"),
("report.pdf", "file"),
],
)
async def test_send_uses_expected_feishu_msg_type_for_uploaded_files(
tmp_path: Path, filename: str, expected_msg_type: str
) -> None:
channel = _make_feishu_channel()
file_path = tmp_path / filename
file_path.write_bytes(b"demo")
send_calls: list[tuple[str, str, str, str]] = []
def _record_send(receive_id_type: str, receive_id: str, msg_type: str, content: str) -> None:
send_calls.append((receive_id_type, receive_id, msg_type, content))
with patch.object(channel, "_upload_file_sync", return_value="file-key"), patch.object(
channel, "_send_message_sync", side_effect=_record_send
):
await channel.send(
OutboundMessage(
channel="feishu",
chat_id="oc_test",
content="",
media=[str(file_path)],
metadata={},
)
)
assert len(send_calls) == 1
receive_id_type, receive_id, msg_type, content = send_calls[0]
assert receive_id_type == "chat_id"
assert receive_id == "oc_test"
assert msg_type == expected_msg_type
assert json.loads(content) == {"file_key": "file-key"}
# ---------------------------------------------------------------------------
# send() — reply routing tests
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_send_uses_reply_api_when_configured() -> None:
channel = _make_feishu_channel(reply_to_message=True)
reply_resp = MagicMock()
reply_resp.success.return_value = True
channel._client.im.v1.message.reply.return_value = reply_resp
await channel.send(OutboundMessage(
channel="feishu",
chat_id="oc_abc",
content="hello",
metadata={"message_id": "om_001"},
))
channel._client.im.v1.message.reply.assert_called_once()
channel._client.im.v1.message.create.assert_not_called()
@pytest.mark.asyncio
async def test_send_uses_create_api_when_reply_disabled() -> None:
channel = _make_feishu_channel(reply_to_message=False)
create_resp = MagicMock()
create_resp.success.return_value = True
channel._client.im.v1.message.create.return_value = create_resp
await channel.send(OutboundMessage(
channel="feishu",
chat_id="oc_abc",
content="hello",
metadata={"message_id": "om_001"},
))
channel._client.im.v1.message.create.assert_called_once()
channel._client.im.v1.message.reply.assert_not_called()
@pytest.mark.asyncio
async def test_send_uses_create_api_when_no_message_id() -> None:
channel = _make_feishu_channel(reply_to_message=True)
create_resp = MagicMock()
create_resp.success.return_value = True
channel._client.im.v1.message.create.return_value = create_resp
await channel.send(OutboundMessage(
channel="feishu",
chat_id="oc_abc",
content="hello",
metadata={},
))
channel._client.im.v1.message.create.assert_called_once()
channel._client.im.v1.message.reply.assert_not_called()
@pytest.mark.asyncio
async def test_send_skips_reply_for_progress_messages() -> None:
channel = _make_feishu_channel(reply_to_message=True)
create_resp = MagicMock()
create_resp.success.return_value = True
channel._client.im.v1.message.create.return_value = create_resp
await channel.send(OutboundMessage(
channel="feishu",
chat_id="oc_abc",
content="thinking...",
metadata={"message_id": "om_001", "_progress": True},
))
channel._client.im.v1.message.create.assert_called_once()
channel._client.im.v1.message.reply.assert_not_called()
@pytest.mark.asyncio
async def test_send_fallback_to_create_when_reply_fails() -> None:
channel = _make_feishu_channel(reply_to_message=True)
reply_resp = MagicMock()
reply_resp.success.return_value = False
reply_resp.code = 400
reply_resp.msg = "error"
reply_resp.get_log_id.return_value = "log_x"
channel._client.im.v1.message.reply.return_value = reply_resp
create_resp = MagicMock()
create_resp.success.return_value = True
channel._client.im.v1.message.create.return_value = create_resp
await channel.send(OutboundMessage(
channel="feishu",
chat_id="oc_abc",
content="hello",
metadata={"message_id": "om_001"},
))
# reply attempted first, then falls back to create
channel._client.im.v1.message.reply.assert_called_once()
channel._client.im.v1.message.create.assert_called_once()
# ---------------------------------------------------------------------------
# _on_message — parent_id / root_id metadata tests
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_on_message_captures_parent_and_root_id_in_metadata() -> None:
channel = _make_feishu_channel()
channel._processed_message_ids.clear()
channel._client.im.v1.message.react.return_value = MagicMock(success=lambda: True)
captured = []
async def _capture(**kwargs):
captured.append(kwargs)
channel._handle_message = _capture
with patch.object(channel, "_add_reaction", return_value=None):
await channel._on_message(
_make_feishu_event(
parent_id="om_parent",
root_id="om_root",
)
)
assert len(captured) == 1
meta = captured[0]["metadata"]
assert meta["parent_id"] == "om_parent"
assert meta["root_id"] == "om_root"
assert meta["message_id"] == "om_001"
@pytest.mark.asyncio
async def test_on_message_parent_and_root_id_none_when_absent() -> None:
channel = _make_feishu_channel()
channel._processed_message_ids.clear()
captured = []
async def _capture(**kwargs):
captured.append(kwargs)
channel._handle_message = _capture
with patch.object(channel, "_add_reaction", return_value=None):
await channel._on_message(_make_feishu_event())
assert len(captured) == 1
meta = captured[0]["metadata"]
assert meta["parent_id"] is None
assert meta["root_id"] is None
@pytest.mark.asyncio
async def test_on_message_prepends_reply_context_when_parent_id_present() -> None:
channel = _make_feishu_channel()
channel._processed_message_ids.clear()
channel._client.im.v1.message.get.return_value = _make_get_message_response("original question")
captured = []
async def _capture(**kwargs):
captured.append(kwargs)
channel._handle_message = _capture
with patch.object(channel, "_add_reaction", return_value=None):
await channel._on_message(
_make_feishu_event(
content='{"text": "my answer"}',
parent_id="om_parent",
)
)
assert len(captured) == 1
content = captured[0]["content"]
assert content.startswith("[Reply to: original question]")
assert "my answer" in content
@pytest.mark.asyncio
async def test_on_message_no_extra_api_call_when_no_parent_id() -> None:
channel = _make_feishu_channel()
channel._processed_message_ids.clear()
captured = []
async def _capture(**kwargs):
captured.append(kwargs)
channel._handle_message = _capture
with patch.object(channel, "_add_reaction", return_value=None):
await channel._on_message(_make_feishu_event())
channel._client.im.v1.message.get.assert_not_called()
assert len(captured) == 1

View File

@@ -12,6 +12,8 @@ class _FakeAsyncWebClient:
def __init__(self) -> None: def __init__(self) -> None:
self.chat_post_calls: list[dict[str, object | None]] = [] self.chat_post_calls: list[dict[str, object | None]] = []
self.file_upload_calls: list[dict[str, object | None]] = [] self.file_upload_calls: list[dict[str, object | None]] = []
self.reactions_add_calls: list[dict[str, object | None]] = []
self.reactions_remove_calls: list[dict[str, object | None]] = []
async def chat_postMessage( async def chat_postMessage(
self, self,
@@ -43,6 +45,36 @@ class _FakeAsyncWebClient:
} }
) )
async def reactions_add(
self,
*,
channel: str,
name: str,
timestamp: str,
) -> None:
self.reactions_add_calls.append(
{
"channel": channel,
"name": name,
"timestamp": timestamp,
}
)
async def reactions_remove(
self,
*,
channel: str,
name: str,
timestamp: str,
) -> None:
self.reactions_remove_calls.append(
{
"channel": channel,
"name": name,
"timestamp": timestamp,
}
)
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_send_uses_thread_for_channel_messages() -> None: async def test_send_uses_thread_for_channel_messages() -> None:
@@ -88,3 +120,28 @@ async def test_send_omits_thread_for_dm_messages() -> None:
assert fake_web.chat_post_calls[0]["thread_ts"] is None assert fake_web.chat_post_calls[0]["thread_ts"] is None
assert len(fake_web.file_upload_calls) == 1 assert len(fake_web.file_upload_calls) == 1
assert fake_web.file_upload_calls[0]["thread_ts"] is None assert fake_web.file_upload_calls[0]["thread_ts"] is None
@pytest.mark.asyncio
async def test_send_updates_reaction_when_final_response_sent() -> None:
channel = SlackChannel(SlackConfig(enabled=True, react_emoji="eyes"), MessageBus())
fake_web = _FakeAsyncWebClient()
channel._web_client = fake_web
await channel.send(
OutboundMessage(
channel="slack",
chat_id="C123",
content="done",
metadata={
"slack": {"event": {"ts": "1700000000.000100"}, "channel_type": "channel"},
},
)
)
assert fake_web.reactions_remove_calls == [
{"channel": "C123", "name": "eyes", "timestamp": "1700000000.000100"}
]
assert fake_web.reactions_add_calls == [
{"channel": "C123", "name": "white_check_mark", "timestamp": "1700000000.000100"}
]