From 22e129b5145f5b18a20608dfaa14206346e98bf6 Mon Sep 17 00:00:00 2001 From: danfeiyang <1243702693@qq.com> Date: Wed, 25 Feb 2026 01:40:25 +0800 Subject: [PATCH 01/13] =?UTF-8?q?fix=EF=BC=9AWorkspace=20path=20in=20onboa?= =?UTF-8?q?rd=20command=20ignores=20config=20setting?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- nanobot/cli/commands.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/nanobot/cli/commands.py b/nanobot/cli/commands.py index 1c20b50fd..acea9e22b 100644 --- a/nanobot/cli/commands.py +++ b/nanobot/cli/commands.py @@ -178,8 +178,9 @@ def onboard(): save_config(Config()) console.print(f"[green]✓[/green] Created config at {config_path}") - # Create workspace - workspace = get_workspace_path() + # Create workspace , use config workspace path if exists, otherwise use ~/.nanobot/workspace; try './workspace' will create a workspace + # on the root dir of the project + workspace = get_workspace_path(config.workspace_path) if not workspace.exists(): workspace.mkdir(parents=True, exist_ok=True) From dfb4537867194ad6b9c01afb411d3f3f90d593cc Mon Sep 17 00:00:00 2001 From: skiyo Date: Mon, 9 Mar 2026 16:17:01 +0800 Subject: [PATCH 02/13] feat: add --dir option to onboard command for Multiple Instances - Add --dir parameter to specify custom base directory for config and workspace - Enables Multiple Instances initialization with isolated configurations - Config and workspace are created under the specified directory - Maintains backward compatibility with default ~/.nanobot/ - Updates help text and next steps with actual paths - Updates README.md with --dir usage examples for Multiple Instances Example usage: nanobot onboard --dir ~/.nanobot-A nanobot onboard --dir ~/.nanobot-B nanobot onboard # uses default ~/.nanobot/ --- README.md | 18 +++++++++++++++++- nanobot/cli/commands.py | 41 ++++++++++++++++++++++++++--------------- 2 files changed, 43 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index f169bd753..78dec7391 100644 --- a/README.md +++ b/README.md @@ -939,6 +939,21 @@ Run multiple nanobot instances simultaneously with separate configs and runtime ### Quick Start +**Initialize instances:** + +```bash +# Create separate instance directories +nanobot onboard --dir ~/.nanobot-telegram +nanobot onboard --dir ~/.nanobot-discord +nanobot onboard --dir ~/.nanobot-feishu +``` + +**Configure each instance:** + +Edit `~/.nanobot-telegram/config.json`, `~/.nanobot-discord/config.json`, etc. with different channel settings and workspaces. + +**Run instances:** + ```bash # Instance A - Telegram bot nanobot gateway --config ~/.nanobot-telegram/config.json @@ -1038,7 +1053,8 @@ nanobot gateway --config ~/.nanobot-telegram/config.json --workspace /tmp/nanobo | Command | Description | |---------|-------------| -| `nanobot onboard` | Initialize config & workspace | +| `nanobot onboard` | Initialize config & workspace at `~/.nanobot/` | +| `nanobot onboard --dir ` | Initialize config & workspace at custom directory | | `nanobot agent -m "..."` | Chat with the agent | | `nanobot agent -w ` | Chat against a specific workspace | | `nanobot agent -w -c ` | Chat against a specific workspace/config | diff --git a/nanobot/cli/commands.py b/nanobot/cli/commands.py index 2c8d6d338..def014413 100644 --- a/nanobot/cli/commands.py +++ b/nanobot/cli/commands.py @@ -168,43 +168,54 @@ def main( @app.command() -def onboard(): +def onboard( + dir: str | None = typer.Option(None, "--dir", help="Base directory for config and workspace (default: ~/.nanobot/)"), +): """Initialize nanobot configuration and workspace.""" - from nanobot.config.loader import get_config_path, load_config, save_config + from nanobot.config.loader import load_config, save_config from nanobot.config.schema import Config - config_path = get_config_path() + # Determine base directory + if dir: + base_dir = Path(dir).expanduser().resolve() + else: + base_dir = Path.home() / ".nanobot" + config_path = base_dir / "config.json" + workspace_path = base_dir / "workspace" + + # Ensure base directory exists + base_dir.mkdir(parents=True, exist_ok=True) + + # Create or update config if config_path.exists(): console.print(f"[yellow]Config already exists at {config_path}[/yellow]") console.print(" [bold]y[/bold] = overwrite with defaults (existing values will be lost)") console.print(" [bold]N[/bold] = refresh config, keeping existing values and adding new fields") if typer.confirm("Overwrite?"): config = Config() - save_config(config) + save_config(config, config_path) console.print(f"[green]✓[/green] Config reset to defaults at {config_path}") else: - config = load_config() - save_config(config) + config = load_config(config_path) + save_config(config, config_path) console.print(f"[green]✓[/green] Config refreshed at {config_path} (existing values preserved)") else: - save_config(Config()) + save_config(Config(), config_path) console.print(f"[green]✓[/green] Created config at {config_path}") # Create workspace - workspace = get_workspace_path() + if not workspace_path.exists(): + workspace_path.mkdir(parents=True, exist_ok=True) + console.print(f"[green]✓[/green] Created workspace at {workspace_path}") - if not workspace.exists(): - workspace.mkdir(parents=True, exist_ok=True) - console.print(f"[green]✓[/green] Created workspace at {workspace}") - - sync_workspace_templates(workspace) + sync_workspace_templates(workspace_path) console.print(f"\n{__logo__} nanobot is ready!") console.print("\nNext steps:") - console.print(" 1. Add your API key to [cyan]~/.nanobot/config.json[/cyan]") + console.print(f" 1. Add your API key to [cyan]{config_path}[/cyan]") console.print(" Get one at: https://openrouter.ai/keys") - console.print(" 2. Chat: [cyan]nanobot agent -m \"Hello!\"[/cyan]") + console.print(f" 2. Chat: [cyan]nanobot agent -m \"Hello!\" --config {config_path} --workspace {workspace_path}[/cyan]") console.print("\n[dim]Want Telegram/WhatsApp? See: https://github.com/HKUDS/nanobot#-chat-apps[/dim]") From db37ecbfd290e043625a94192ac0d9540c9593a8 Mon Sep 17 00:00:00 2001 From: Xubin Ren Date: Tue, 17 Mar 2026 04:28:24 +0000 Subject: [PATCH 03/13] fix(custom): support extraHeaders for OpenAI-compatible endpoints --- nanobot/cli/commands.py | 1 + nanobot/providers/custom_provider.py | 17 +++++++++++++--- tests/test_commands.py | 29 +++++++++++++++++++++++++++- 3 files changed, 43 insertions(+), 4 deletions(-) diff --git a/nanobot/cli/commands.py b/nanobot/cli/commands.py index 659dd94b5..23b7dfcc1 100644 --- a/nanobot/cli/commands.py +++ b/nanobot/cli/commands.py @@ -364,6 +364,7 @@ def _make_provider(config: Config): api_key=p.api_key if p else "no-key", api_base=config.get_api_base(model) or "http://localhost:8000/v1", default_model=model, + extra_headers=p.extra_headers if p else None, ) # Azure OpenAI: direct Azure OpenAI endpoint with deployment name elif provider_name == "azure_openai": diff --git a/nanobot/providers/custom_provider.py b/nanobot/providers/custom_provider.py index f16c69b3c..e177e55d9 100644 --- a/nanobot/providers/custom_provider.py +++ b/nanobot/providers/custom_provider.py @@ -13,14 +13,25 @@ from nanobot.providers.base import LLMProvider, LLMResponse, ToolCallRequest class CustomProvider(LLMProvider): - def __init__(self, api_key: str = "no-key", api_base: str = "http://localhost:8000/v1", default_model: str = "default"): + def __init__( + self, + api_key: str = "no-key", + api_base: str = "http://localhost:8000/v1", + default_model: str = "default", + extra_headers: dict[str, str] | None = None, + ): super().__init__(api_key, api_base) self.default_model = default_model - # Keep affinity stable for this provider instance to improve backend cache locality. + # Keep affinity stable for this provider instance to improve backend cache locality, + # while still letting users attach provider-specific headers for custom gateways. + default_headers = { + "x-session-affinity": uuid.uuid4().hex, + **(extra_headers or {}), + } self._client = AsyncOpenAI( api_key=api_key, base_url=api_base, - default_headers={"x-session-affinity": uuid.uuid4().hex}, + default_headers=default_headers, ) async def chat(self, messages: list[dict[str, Any]], tools: list[dict[str, Any]] | None = None, diff --git a/tests/test_commands.py b/tests/test_commands.py index cb77bde0b..b09c95553 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -6,7 +6,7 @@ from unittest.mock import AsyncMock, MagicMock, patch import pytest from typer.testing import CliRunner -from nanobot.cli.commands import app +from nanobot.cli.commands import _make_provider, app from nanobot.config.schema import Config from nanobot.providers.litellm_provider import LiteLLMProvider from nanobot.providers.openai_codex_provider import _strip_model_prefix @@ -199,6 +199,33 @@ def test_openai_codex_strip_prefix_supports_hyphen_and_underscore(): assert _strip_model_prefix("openai_codex/gpt-5.1-codex") == "gpt-5.1-codex" +def test_make_provider_passes_extra_headers_to_custom_provider(): + config = Config.model_validate( + { + "agents": {"defaults": {"provider": "custom", "model": "gpt-4o-mini"}}, + "providers": { + "custom": { + "apiKey": "test-key", + "apiBase": "https://example.com/v1", + "extraHeaders": { + "APP-Code": "demo-app", + "x-session-affinity": "sticky-session", + }, + } + }, + } + ) + + with patch("nanobot.providers.custom_provider.AsyncOpenAI") as mock_async_openai: + _make_provider(config) + + kwargs = mock_async_openai.call_args.kwargs + assert kwargs["api_key"] == "test-key" + assert kwargs["base_url"] == "https://example.com/v1" + assert kwargs["default_headers"]["APP-Code"] == "demo-app" + assert kwargs["default_headers"]["x-session-affinity"] == "sticky-session" + + @pytest.fixture def mock_agent_runtime(tmp_path): """Mock agent command dependencies for focused CLI tests.""" From 40a022afd9e5f15db74fb9208c4ec423d799f945 Mon Sep 17 00:00:00 2001 From: Xubin Ren Date: Tue, 17 Mar 2026 05:01:34 +0000 Subject: [PATCH 04/13] fix(onboard): use configured workspace path on setup --- nanobot/cli/commands.py | 3 ++- tests/test_commands.py | 12 +++++++----- tests/test_config_migration.py | 4 ++-- 3 files changed, 11 insertions(+), 8 deletions(-) diff --git a/nanobot/cli/commands.py b/nanobot/cli/commands.py index 47f7316bb..a1a6341ee 100644 --- a/nanobot/cli/commands.py +++ b/nanobot/cli/commands.py @@ -282,7 +282,8 @@ def onboard(): save_config(config) console.print(f"[green]✓[/green] Config refreshed at {config_path} (existing values preserved)") else: - save_config(Config()) + config = Config() + save_config(config) console.print(f"[green]✓[/green] Created config at {config_path}") console.print("[dim]Config template now uses `maxTokens` + `contextWindowTokens`; `memoryWindow` is no longer a runtime setting.[/dim]") diff --git a/tests/test_commands.py b/tests/test_commands.py index b09c95553..ce36a6d8f 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -45,7 +45,7 @@ def mock_paths(): mock_ws.return_value = workspace_dir mock_sc.side_effect = lambda config: config_file.write_text("{}") - yield config_file, workspace_dir + yield config_file, workspace_dir, mock_ws if base_dir.exists(): shutil.rmtree(base_dir) @@ -53,7 +53,7 @@ def mock_paths(): def test_onboard_fresh_install(mock_paths): """No existing config — should create from scratch.""" - config_file, workspace_dir = mock_paths + config_file, workspace_dir, mock_ws = mock_paths result = runner.invoke(app, ["onboard"]) @@ -64,11 +64,13 @@ def test_onboard_fresh_install(mock_paths): assert config_file.exists() assert (workspace_dir / "AGENTS.md").exists() assert (workspace_dir / "memory" / "MEMORY.md").exists() + expected_workspace = Config().workspace_path + assert mock_ws.call_args.args == (expected_workspace,) def test_onboard_existing_config_refresh(mock_paths): """Config exists, user declines overwrite — should refresh (load-merge-save).""" - config_file, workspace_dir = mock_paths + config_file, workspace_dir, _ = mock_paths config_file.write_text('{"existing": true}') result = runner.invoke(app, ["onboard"], input="n\n") @@ -82,7 +84,7 @@ def test_onboard_existing_config_refresh(mock_paths): def test_onboard_existing_config_overwrite(mock_paths): """Config exists, user confirms overwrite — should reset to defaults.""" - config_file, workspace_dir = mock_paths + config_file, workspace_dir, _ = mock_paths config_file.write_text('{"existing": true}') result = runner.invoke(app, ["onboard"], input="y\n") @@ -95,7 +97,7 @@ def test_onboard_existing_config_overwrite(mock_paths): def test_onboard_existing_workspace_safe_create(mock_paths): """Workspace exists — should not recreate, but still add missing templates.""" - config_file, workspace_dir = mock_paths + config_file, workspace_dir, _ = mock_paths workspace_dir.mkdir(parents=True) config_file.write_text("{}") diff --git a/tests/test_config_migration.py b/tests/test_config_migration.py index f800fb59e..2a446b774 100644 --- a/tests/test_config_migration.py +++ b/tests/test_config_migration.py @@ -76,7 +76,7 @@ def test_onboard_refresh_rewrites_legacy_config_template(tmp_path, monkeypatch) ) monkeypatch.setattr("nanobot.config.loader.get_config_path", lambda: config_path) - monkeypatch.setattr("nanobot.cli.commands.get_workspace_path", lambda: workspace) + monkeypatch.setattr("nanobot.cli.commands.get_workspace_path", lambda _workspace=None: workspace) result = runner.invoke(app, ["onboard"], input="n\n") @@ -109,7 +109,7 @@ def test_onboard_refresh_backfills_missing_channel_fields(tmp_path, monkeypatch) ) monkeypatch.setattr("nanobot.config.loader.get_config_path", lambda: config_path) - monkeypatch.setattr("nanobot.cli.commands.get_workspace_path", lambda: workspace) + monkeypatch.setattr("nanobot.cli.commands.get_workspace_path", lambda _workspace=None: workspace) monkeypatch.setattr( "nanobot.channels.registry.discover_all", lambda: { From 499d0e15887c7745beaf7664a5e7e50712dc7eef Mon Sep 17 00:00:00 2001 From: Xubin Ren Date: Tue, 17 Mar 2026 05:58:13 +0000 Subject: [PATCH 05/13] docs(readme): update multi-instance onboard examples --- README.md | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 57898c6f8..0410a351d 100644 --- a/README.md +++ b/README.md @@ -1162,22 +1162,24 @@ MCP tools are automatically discovered and registered on startup. The LLM can us ## 🧩 Multiple Instances -Run multiple nanobot instances simultaneously with separate configs and runtime data. Use `--config` as the main entrypoint, and optionally use `--workspace` to override the workspace for a specific run. +Run multiple nanobot instances simultaneously with separate configs and runtime data. Use `--config` as the main entrypoint. Optionally pass `--workspace` during `onboard` when you want to initialize or update the saved workspace for a specific instance. ### Quick Start +If you want each instance to have its own dedicated workspace from the start, pass both `--config` and `--workspace` during onboarding. + **Initialize instances:** ```bash -# Create separate instance directories -nanobot onboard --dir ~/.nanobot-telegram -nanobot onboard --dir ~/.nanobot-discord -nanobot onboard --dir ~/.nanobot-feishu +# Create separate instance configs and workspaces +nanobot onboard --config ~/.nanobot-telegram/config.json --workspace ~/.nanobot-telegram/workspace +nanobot onboard --config ~/.nanobot-discord/config.json --workspace ~/.nanobot-discord/workspace +nanobot onboard --config ~/.nanobot-feishu/config.json --workspace ~/.nanobot-feishu/workspace ``` **Configure each instance:** -Edit `~/.nanobot-telegram/config.json`, `~/.nanobot-discord/config.json`, etc. with different channel settings and workspaces. +Edit `~/.nanobot-telegram/config.json`, `~/.nanobot-discord/config.json`, etc. with different channel settings. The workspace you passed during `onboard` is saved into each config as that instance's default workspace. **Run instances:** @@ -1281,7 +1283,7 @@ nanobot gateway --config ~/.nanobot-telegram/config.json --workspace /tmp/nanobo | Command | Description | |---------|-------------| | `nanobot onboard` | Initialize config & workspace at `~/.nanobot/` | -| `nanobot onboard --dir ` | Initialize config & workspace at custom directory | +| `nanobot onboard -c -w ` | Initialize or refresh a specific instance config and workspace | | `nanobot agent -m "..."` | Chat with the agent | | `nanobot agent -w ` | Chat against a specific workspace | | `nanobot agent -w -c ` | Chat against a specific workspace/config | From 2eb0c283e9a3f68ac5c1a874314710c495e46f72 Mon Sep 17 00:00:00 2001 From: Jiajun Xie Date: Tue, 17 Mar 2026 13:25:08 +0800 Subject: [PATCH 06/13] fix(providers): handle empty choices in custom provider response --- nanobot/providers/custom_provider.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/nanobot/providers/custom_provider.py b/nanobot/providers/custom_provider.py index e177e55d9..4bdeb5429 100644 --- a/nanobot/providers/custom_provider.py +++ b/nanobot/providers/custom_provider.py @@ -54,6 +54,11 @@ class CustomProvider(LLMProvider): return LLMResponse(content=f"Error: {e}", finish_reason="error") def _parse(self, response: Any) -> LLMResponse: + if not response.choices: + return LLMResponse( + content="Error: API returned empty choices. This may indicate a temporary service issue or an invalid model response.", + finish_reason="error" + ) choice = response.choices[0] msg = choice.message tool_calls = [ From 49fc50b1e623ee3a03a6e86b2dd6e90d956aaae2 Mon Sep 17 00:00:00 2001 From: Xubin Ren Date: Tue, 17 Mar 2026 06:20:19 +0000 Subject: [PATCH 07/13] test(custom): cover empty choices response handling --- tests/test_custom_provider.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 tests/test_custom_provider.py diff --git a/tests/test_custom_provider.py b/tests/test_custom_provider.py new file mode 100644 index 000000000..463affedc --- /dev/null +++ b/tests/test_custom_provider.py @@ -0,0 +1,13 @@ +from types import SimpleNamespace + +from nanobot.providers.custom_provider import CustomProvider + + +def test_custom_provider_parse_handles_empty_choices() -> None: + provider = CustomProvider() + response = SimpleNamespace(choices=[]) + + result = provider._parse(response) + + assert result.finish_reason == "error" + assert "empty choices" in result.content From 8aebe20caca6684610ce44496f5e95e002e525c4 Mon Sep 17 00:00:00 2001 From: Sihyeon Jang Date: Wed, 11 Mar 2026 07:40:43 +0900 Subject: [PATCH 08/13] 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 c9f353d65..1b683f755 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 033fb633a..c067231a5 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 09/13] 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 1b683f755..87194ac70 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 b4d94929b..d243235aa 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 10/13] 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 f6573592e..bbe5281b5 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 11/13] 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 bbe5281b5..d450e25f5 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 12/13] 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 d450e25f5..695689e99 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 13/13] 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 65d7f862e..b2072b31a 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 # ---------------------------------------------------------------------------