From 8aebe20caca6684610ce44496f5e95e002e525c4 Mon Sep 17 00:00:00 2001 From: Sihyeon Jang Date: Wed, 11 Mar 2026 07:40:43 +0900 Subject: [PATCH 1/6] 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 --- nanobot/channels/slack.py | 28 ++++++++++++++++++++++++++++ nanobot/config/schema.py | 1 - 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/nanobot/channels/slack.py b/nanobot/channels/slack.py index c9f353d..1b683f7 100644 --- a/nanobot/channels/slack.py +++ b/nanobot/channels/slack.py @@ -136,6 +136,12 @@ class SlackChannel(BaseChannel): ) except Exception as 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: logger.error("Error sending Slack message: {}", e) @@ -233,6 +239,28 @@ class SlackChannel(BaseChannel): except Exception: 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: if channel_type == "im": if not self.config.dm.enabled: diff --git a/nanobot/config/schema.py b/nanobot/config/schema.py index 033fb63..c067231 100644 --- a/nanobot/config/schema.py +++ b/nanobot/config/schema.py @@ -13,7 +13,6 @@ class Base(BaseModel): model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True) - class ChannelsConfig(Base): """Configuration for chat channels. From 91ca82035a18c37874067f281a17134bccee355e Mon Sep 17 00:00:00 2001 From: Xubin Ren Date: Tue, 17 Mar 2026 08:00:05 +0000 Subject: [PATCH 2/6] feat(slack): add default done reaction on completion --- nanobot/channels/slack.py | 1 + tests/test_slack_channel.py | 57 +++++++++++++++++++++++++++++++++++++ 2 files changed, 58 insertions(+) diff --git a/nanobot/channels/slack.py b/nanobot/channels/slack.py index 1b683f7..87194ac 100644 --- a/nanobot/channels/slack.py +++ b/nanobot/channels/slack.py @@ -38,6 +38,7 @@ class SlackConfig(Base): user_token_read_only: bool = True reply_in_thread: bool = True react_emoji: str = "eyes" + done_emoji: str = "white_check_mark" allow_from: list[str] = Field(default_factory=list) group_policy: str = "mention" group_allow_from: list[str] = Field(default_factory=list) diff --git a/tests/test_slack_channel.py b/tests/test_slack_channel.py index b4d9492..d243235 100644 --- a/tests/test_slack_channel.py +++ b/tests/test_slack_channel.py @@ -12,6 +12,8 @@ class _FakeAsyncWebClient: def __init__(self) -> None: self.chat_post_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( 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 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 len(fake_web.file_upload_calls) == 1 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"} + ] From 9afbf386c4d982fe667d0c1ee6ba9cc57d1efa01 Mon Sep 17 00:00:00 2001 From: Your Name Date: Tue, 10 Mar 2026 12:12:47 +0800 Subject: [PATCH 3/6] 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 --- nanobot/channels/feishu.py | 35 ++++++++++++++++++++++++++++++----- 1 file changed, 30 insertions(+), 5 deletions(-) diff --git a/nanobot/channels/feishu.py b/nanobot/channels/feishu.py index f657359..bbe5281 100644 --- a/nanobot/channels/feishu.py +++ b/nanobot/channels/feishu.py @@ -437,16 +437,36 @@ class FeishuChannel(BaseChannel): _CODE_BLOCK_RE = re.compile(r"(```[\s\S]*?```)", re.MULTILINE) - @staticmethod - def _parse_md_table(table_text: str) -> dict | None: + # Markdown bold/italic patterns that need to be stripped for table cells + _MD_BOLD_RE = re.compile(r"\*\*(.+?)\*\*") + _MD_ITALIC_RE = re.compile(r"(? 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) + # 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.""" lines = [_line.strip() for _line in table_text.strip().split("\n") if _line.strip()] if len(lines) < 3: return None def split(_line: str) -> list[str]: return [c.strip() for c in _line.strip("|").split("|")] - headers = split(lines[0]) - rows = [split(_line) for _line in lines[2:]] + headers = [cls._strip_md_formatting(h) for h in split(lines[0])] + 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"} for i, h in enumerate(headers)] return { @@ -513,11 +533,16 @@ class FeishuChannel(BaseChannel): if before: elements.append({"tag": "markdown", "content": before}) text = m.group(2).strip() + # Avoid double bold markers if text already contains them + if text.startswith("**") and text.endswith("**"): + display_text = text + else: + display_text = f"**{text}**" elements.append({ "tag": "div", "text": { "tag": "lark_md", - "content": f"**{text}**", + "content": display_text, }, }) last_end = m.end() From 41d59c3b89494c16e2dfa83bb9b5cf831eed2f5b Mon Sep 17 00:00:00 2001 From: Xubin Ren Date: Tue, 17 Mar 2026 08:40:39 +0000 Subject: [PATCH 4/6] test(feishu): cover heading and table markdown rendering --- nanobot/channels/feishu.py | 13 +++--- tests/test_feishu_markdown_rendering.py | 57 +++++++++++++++++++++++++ 2 files changed, 63 insertions(+), 7 deletions(-) create mode 100644 tests/test_feishu_markdown_rendering.py diff --git a/nanobot/channels/feishu.py b/nanobot/channels/feishu.py index bbe5281..d450e25 100644 --- a/nanobot/channels/feishu.py +++ b/nanobot/channels/feishu.py @@ -437,8 +437,10 @@ class FeishuChannel(BaseChannel): _CODE_BLOCK_RE = re.compile(r"(```[\s\S]*?```)", re.MULTILINE) - # Markdown bold/italic patterns that need to be stripped for table cells + # Markdown formatting patterns that should be stripped from plain-text + # 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"(? 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"] From 47e2a1e8d707b5c29e41389364ac7aa31db147b4 Mon Sep 17 00:00:00 2001 From: weipeng0098 Date: Mon, 9 Mar 2026 11:20:41 +0800 Subject: [PATCH 5/6] fix(feishu): use correct msg_type for audio/video files --- nanobot/channels/feishu.py | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/nanobot/channels/feishu.py b/nanobot/channels/feishu.py index d450e25..695689e 100644 --- a/nanobot/channels/feishu.py +++ b/nanobot/channels/feishu.py @@ -985,10 +985,13 @@ class FeishuChannel(BaseChannel): else: key = await loop.run_in_executor(None, self._upload_file_sync, file_path) if key: - # Use msg_type "media" for audio/video so users can play inline; - # "file" for everything else (documents, archives, etc.) - if ext in self._AUDIO_EXTS or ext in self._VIDEO_EXTS: - media_type = "media" + # Use msg_type "audio" for audio, "video" for video, "file" for documents. + # Feishu requires these specific msg_types for inline playback. + # Note: "media" is only valid as a tag inside "post" messages, not as a standalone msg_type. + if ext in self._AUDIO_EXTS: + media_type = "audio" + elif ext in self._VIDEO_EXTS: + media_type = "video" else: media_type = "file" await loop.run_in_executor( From 7086f57d05f33d4bcab553bd7ed52e505fd97ff7 Mon Sep 17 00:00:00 2001 From: Xubin Ren Date: Tue, 17 Mar 2026 09:01:09 +0000 Subject: [PATCH 6/6] test(feishu): cover media msg_type mapping --- tests/test_feishu_reply.py | 43 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/tests/test_feishu_reply.py b/tests/test_feishu_reply.py index 65d7f86..b2072b3 100644 --- a/tests/test_feishu_reply.py +++ b/tests/test_feishu_reply.py @@ -1,6 +1,7 @@ """Tests for Feishu message reply (quote) feature.""" import asyncio import json +from pathlib import Path from types import SimpleNamespace from unittest.mock import MagicMock, patch @@ -186,6 +187,48 @@ def test_reply_message_sync_returns_false_on_exception() -> None: 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 # ---------------------------------------------------------------------------