diff --git a/plugins/platforms/discord/adapter.py b/plugins/platforms/discord/adapter.py index 250800d4fc10..161669a8a6ec 100644 --- a/plugins/platforms/discord/adapter.py +++ b/plugins/platforms/discord/adapter.py @@ -9272,7 +9272,7 @@ def interactive_setup() -> None: the plugin's import surface stays small, prompts for the bot token, captures an allowlist, and offers to set a home channel. """ - from hermes_cli.config import get_env_value, save_env_value + from hermes_cli.config import get_env_value, remove_env_value, save_env_value from hermes_cli.cli_output import ( prompt, prompt_yes_no, @@ -9336,9 +9336,12 @@ def interactive_setup() -> None: print_info(" To get a channel ID: right-click a channel → Copy Channel ID") print_info(" (requires Developer Mode in Discord settings)") print_info(" You can also set this later by typing /set-home in a Discord channel.") - home_channel = prompt("Home channel ID (leave empty to set later with /set-home)") + home_channel = prompt("Home channel ID (leave empty to set later with /set-home)").strip() if home_channel: save_env_value("DISCORD_HOME_CHANNEL", home_channel) + else: + if remove_env_value("DISCORD_HOME_CHANNEL"): + print_info("Home channel cleared.") def _apply_yaml_config(yaml_cfg: dict, discord_cfg: dict) -> dict | None: diff --git a/plugins/platforms/feishu/adapter.py b/plugins/platforms/feishu/adapter.py index da15ddfb3ad8..93571de06199 100644 --- a/plugins/platforms/feishu/adapter.py +++ b/plugins/platforms/feishu/adapter.py @@ -5520,7 +5520,7 @@ def interactive_setup() -> None: Replaces the central _setup_feishu in hermes_cli/gateway.py and the static _PLATFORMS["feishu"] dict. CLI helpers are lazy-imported. """ - from hermes_cli.config import get_env_value, save_env_value + from hermes_cli.config import get_env_value, remove_env_value, save_env_value from hermes_cli.setup import prompt_choice from hermes_cli.cli_output import ( prompt, @@ -5671,10 +5671,17 @@ def interactive_setup() -> None: save_env_value("FEISHU_GROUP_POLICY", "disabled") print_info("Group chats disabled.") - home_channel = prompt("Home chat ID (optional, for cron/notifications)", password=False) + print_info( + "Leave blank to clear a previously saved home channel " + "(cron / notifications)." + ) + home_channel = prompt("Home chat ID (optional, for cron/notifications)", password=False).strip() if home_channel: save_env_value("FEISHU_HOME_CHANNEL", home_channel) print_success(f"Home channel set to {home_channel}") + else: + if remove_env_value("FEISHU_HOME_CHANNEL"): + print_info("Home channel cleared.") print_success("🪽 Feishu / Lark configured!") print_info(f"App ID: {app_id}") diff --git a/plugins/platforms/matrix/adapter.py b/plugins/platforms/matrix/adapter.py index 2b78ad8043f1..ec6067e7c601 100644 --- a/plugins/platforms/matrix/adapter.py +++ b/plugins/platforms/matrix/adapter.py @@ -4610,7 +4610,7 @@ def interactive_setup() -> None: and the static _PLATFORMS["matrix"] dict. CLI helpers are lazy-imported.""" import shutil import sys as _sys - from hermes_cli.config import get_env_value, save_env_value + from hermes_cli.config import get_env_value, remove_env_value, save_env_value from hermes_cli.cli_output import ( prompt, prompt_yes_no, @@ -4700,9 +4700,13 @@ def interactive_setup() -> None: print_info("📬 Home Room: where Hermes delivers cron job results and notifications.") print_info(" Room IDs look like !abc123:server (shown in Element room settings)") print_info(" You can also set this later by typing /set-home in a Matrix room.") - home_room = prompt("Home room ID (leave empty to set later with /set-home)") + print_info("Leave blank to clear a previously saved home room (cron / notifications).") + home_room = prompt("Home room ID (leave empty to set later with /set-home)").strip() if home_room: save_env_value("MATRIX_HOME_ROOM", home_room) + else: + if remove_env_value("MATRIX_HOME_ROOM"): + print_info("Home room cleared.") def _apply_yaml_config(yaml_cfg: dict, matrix_cfg: dict) -> dict | None: diff --git a/plugins/platforms/mattermost/adapter.py b/plugins/platforms/mattermost/adapter.py index 6efb0017e1a9..63e7cf266f8e 100644 --- a/plugins/platforms/mattermost/adapter.py +++ b/plugins/platforms/mattermost/adapter.py @@ -1136,7 +1136,7 @@ def interactive_setup() -> None: ``hermes_cli/setup.py::_setup_mattermost`` function this migration removes. """ - from hermes_cli.config import get_env_value, save_env_value + from hermes_cli.config import get_env_value, remove_env_value, save_env_value from hermes_cli.cli_output import ( prompt, prompt_yes_no, @@ -1181,9 +1181,12 @@ def interactive_setup() -> None: print_info("📬 Home Channel: where Hermes delivers cron job results and notifications.") print_info(" To get a channel ID: click channel name → View Info → copy the ID") print_info(" You can also set this later by typing /set-home in a Mattermost channel.") - home_channel = prompt("Home channel ID (leave empty to set later with /set-home)") + home_channel = prompt("Home channel ID (leave empty to set later with /set-home)").strip() if home_channel: save_env_value("MATTERMOST_HOME_CHANNEL", home_channel) + else: + if remove_env_value("MATTERMOST_HOME_CHANNEL"): + print_info("Home channel cleared.") print_info(" Open config in your editor: hermes config edit") diff --git a/plugins/platforms/slack/adapter.py b/plugins/platforms/slack/adapter.py index b0080a351340..7cae542d7cc6 100644 --- a/plugins/platforms/slack/adapter.py +++ b/plugins/platforms/slack/adapter.py @@ -8729,7 +8729,7 @@ def interactive_setup() -> None: offers to set a home channel. Replaces ``hermes_cli/setup.py::_setup_slack``. """ from pathlib import Path - from hermes_cli.config import get_env_value, save_env_value + from hermes_cli.config import get_env_value, remove_env_value, save_env_value from hermes_cli.cli_output import ( prompt, prompt_yes_no, @@ -8831,9 +8831,12 @@ def interactive_setup() -> None: print_info(" To get a channel ID: open the channel in Slack, then right-click") print_info(" the channel name → Copy link — the ID starts with C (e.g. C01ABC2DE3F).") print_info(" You can also set this later by typing /set-home in a Slack channel.") - home_channel = prompt("Home channel ID (leave empty to set later with /set-home)") + home_channel = prompt("Home channel ID (leave empty to set later with /set-home)").strip() if home_channel: - save_env_value("SLACK_HOME_CHANNEL", home_channel.strip()) + save_env_value("SLACK_HOME_CHANNEL", home_channel) + else: + if remove_env_value("SLACK_HOME_CHANNEL"): + print_info("Home channel cleared.") def _apply_yaml_config(yaml_cfg: dict, slack_cfg: dict) -> dict | None: diff --git a/plugins/platforms/wecom/adapter.py b/plugins/platforms/wecom/adapter.py index 89eeecfde021..2f8e6d66fc31 100644 --- a/plugins/platforms/wecom/adapter.py +++ b/plugins/platforms/wecom/adapter.py @@ -1731,7 +1731,7 @@ def interactive_setup() -> None: Replaces hermes_cli/gateway.py::_setup_wecom and the static _PLATFORMS["wecom"] dict. CLI helpers are lazy-imported. """ - from hermes_cli.config import get_env_value, save_env_value + from hermes_cli.config import get_env_value, remove_env_value, save_env_value from hermes_cli.setup import prompt_choice from hermes_cli.cli_output import ( prompt, @@ -1829,10 +1829,13 @@ def interactive_setup() -> None: else: print_info("Skipped — configure later with 'hermes gateway setup'") - home = prompt("Home chat ID (optional, for cron/notifications)", password=False) + home = prompt("Home chat ID (optional, for cron/notifications)", password=False).strip() if home: save_env_value("WECOM_HOME_CHANNEL", home) print_success(f"Home channel set to {home}") + else: + if remove_env_value("WECOM_HOME_CHANNEL"): + print_info("Home channel cleared.") print_success("💬 WeCom configured!") diff --git a/plugins/platforms/whatsapp/adapter.py b/plugins/platforms/whatsapp/adapter.py index 7cf94b7c1e63..df351f4863a6 100644 --- a/plugins/platforms/whatsapp/adapter.py +++ b/plugins/platforms/whatsapp/adapter.py @@ -1672,7 +1672,7 @@ def interactive_setup() -> None: static _PLATFORMS["whatsapp"] dict. CLI helpers are lazy-imported so the plugin's module-load surface stays minimal. """ - from hermes_cli.config import get_env_value, save_env_value + from hermes_cli.config import get_env_value, remove_env_value, save_env_value from hermes_cli.cli_output import ( prompt, prompt_yes_no, @@ -1705,9 +1705,12 @@ def interactive_setup() -> None: save_env_value("WHATSAPP_ALLOWED_USERS", allowed_users.replace(" ", "")) print_success("WhatsApp allowlist configured") - home_channel = prompt("Home chat ID for cron delivery (leave empty to skip)") + home_channel = prompt("Home chat ID for cron delivery (leave empty to skip)").strip() if home_channel: - save_env_value("WHATSAPP_HOME_CHANNEL", home_channel.strip()) + save_env_value("WHATSAPP_HOME_CHANNEL", home_channel) + else: + if remove_env_value("WHATSAPP_HOME_CHANNEL"): + print_info("Home channel cleared.") def _apply_yaml_config(yaml_cfg: dict, whatsapp_cfg: dict) -> dict | None: diff --git a/tests/gateway/test_discord_plugin_setup.py b/tests/gateway/test_discord_plugin_setup.py new file mode 100644 index 000000000000..c0c3ef866493 --- /dev/null +++ b/tests/gateway/test_discord_plugin_setup.py @@ -0,0 +1,84 @@ +"""Tests for the Discord plugin's interactive_setup wizard home-channel flow. + +The interactive_setup wizard lazy-imports its CLI helpers from +``hermes_cli.config`` (get_env_value / save_env_value / remove_env_value) and +``hermes_cli.cli_output`` (prompt / prompt_yes_no / print_*); we patch those +source modules. Covers the home-channel clear-on-blank behavior added in +PR #58421 and extended in the follow-up. +""" +import hermes_cli.config as config_mod +import hermes_cli.cli_output as cli_output_mod +from plugins.platforms.discord.adapter import interactive_setup + + +def _patch_setup_io(monkeypatch, prompts, saved, removed, existing): + prompt_iter = iter(prompts) + monkeypatch.setattr(config_mod, "get_env_value", lambda key: existing.get(key, "")) + monkeypatch.setattr(config_mod, "save_env_value", lambda k, v: saved.update({k: v})) + + def _remove(key): + removed.append(key) + return existing.pop(key, None) is not None + + monkeypatch.setattr(config_mod, "remove_env_value", _remove) + monkeypatch.setattr(cli_output_mod, "prompt", lambda *_a, **_kw: next(prompt_iter)) + monkeypatch.setattr(cli_output_mod, "prompt_yes_no", lambda *_a, **_kw: False) + for name in ("print_header", "print_info", "print_success", "print_warning"): + monkeypatch.setattr(cli_output_mod, name, lambda *_a, **_kw: None) + + +# Discord prompts: bot_token (password), allowed_users, home_channel. +_PROMPTS_NONEMPTY = ["«redacted:discord-bot-token»", "", "123456789012345678"] +_PROMPTS_BLANK = ["«redacted:discord-bot-token»", "", ""] +_PROMPTS_WHITESPACE = ["«redacted:discord-bot-token»", "", " "] + + +class TestDiscordHomeChannelClear: + """Blank home-channel answer must clear DISCORD_HOME_CHANNEL (#12423).""" + + def test_blank_removes_existing_home_channel(self, monkeypatch, tmp_path): + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + saved, removed = {}, [] + _patch_setup_io( + monkeypatch, + _PROMPTS_BLANK, + saved, + removed, + existing={"DISCORD_HOME_CHANNEL": "987654321098765432"}, + ) + interactive_setup() + assert "DISCORD_HOME_CHANNEL" in removed + assert "DISCORD_HOME_CHANNEL" not in saved + + def test_blank_without_prior_home_still_attempts_remove(self, monkeypatch, tmp_path): + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + saved, removed = {}, [] + _patch_setup_io( + monkeypatch, _PROMPTS_BLANK, saved, removed, existing={} + ) + interactive_setup() + assert removed.count("DISCORD_HOME_CHANNEL") == 1 + + def test_nonempty_saves_home_channel(self, monkeypatch, tmp_path): + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + saved, removed = {}, [] + _patch_setup_io( + monkeypatch, _PROMPTS_NONEMPTY, saved, removed, existing={} + ) + interactive_setup() + assert saved["DISCORD_HOME_CHANNEL"] == "123456789012345678" + assert "DISCORD_HOME_CHANNEL" not in removed + + def test_whitespace_only_clears_home_channel(self, monkeypatch, tmp_path): + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + saved, removed = {}, [] + _patch_setup_io( + monkeypatch, + _PROMPTS_WHITESPACE, + saved, + removed, + existing={"DISCORD_HOME_CHANNEL": "987654321098765432"}, + ) + interactive_setup() + assert "DISCORD_HOME_CHANNEL" in removed + assert "DISCORD_HOME_CHANNEL" not in saved \ No newline at end of file diff --git a/tests/gateway/test_matrix_plugin_setup.py b/tests/gateway/test_matrix_plugin_setup.py new file mode 100644 index 000000000000..48d3bda77488 --- /dev/null +++ b/tests/gateway/test_matrix_plugin_setup.py @@ -0,0 +1,115 @@ +"""Tests for the Matrix plugin's interactive_setup wizard home-channel flow. + +The interactive_setup wizard lazy-imports its CLI helpers from +``hermes_cli.config`` (get_env_value / save_env_value / remove_env_value), +``hermes_cli.cli_output`` (prompt / prompt_yes_no / print_*), and +``tools.lazy_deps`` (mautrix ensure). We patch each at its source module so +the wizard runs without touching pip or the network. Covers the home-channel +clear-on-blank behavior added in the follow-up to PR #58421. +""" +import hermes_cli.config as config_mod +import hermes_cli.cli_output as cli_output_mod +import tools.lazy_deps as lazy_deps_mod +from plugins.platforms.matrix.adapter import interactive_setup + + +def _patch_setup_io(monkeypatch, prompts, yes_no_responses, saved, removed, existing): + prompt_iter = iter(prompts) + yes_no_iter = iter(yes_no_responses) + monkeypatch.setattr(config_mod, "get_env_value", lambda key: existing.get(key, "")) + monkeypatch.setattr(config_mod, "save_env_value", lambda k, v: saved.update({k: v})) + + def _remove(key): + removed.append(key) + return existing.pop(key, None) is not None + + monkeypatch.setattr(config_mod, "remove_env_value", _remove) + monkeypatch.setattr(cli_output_mod, "prompt", lambda *_a, **_kw: next(prompt_iter)) + monkeypatch.setattr( + cli_output_mod, "prompt_yes_no", lambda *_a, **_kw: next(yes_no_iter) + ) + for name in ("print_header", "print_info", "print_success", "print_warning"): + monkeypatch.setattr(cli_output_mod, name, lambda *_a, **_kw: None) + # Block the auto-install path so the test never invokes pip. + monkeypatch.setattr(lazy_deps_mod, "feature_missing", lambda feature: ()) + monkeypatch.setattr(lazy_deps_mod, "ensure", lambda *a, **kw: None) + + +# Matrix prompts (after the E2EE yes_no): allowed_users, home_channel. +# Homeserver, token are still text prompts before E2EE. +_PROMPTS_NONEMPTY = [ + "https://matrix.example.org", # homeserver + "syt_test_token_value", # access token (password) + "@bot:matrix.example.org", # user_id (optional) + "", # allowed_users + "!AbCdEfGhIjKlMn:matrix.example.org", # home room +] +_PROMPTS_BLANK = [ + "https://matrix.example.org", + "syt_test_token_value", + "@bot:matrix.example.org", + "", + "", +] +_PROMPTS_WHITESPACE = [ + "https://matrix.example.org", + "syt_test_token_value", + "@bot:matrix.example.org", + "", + " ", +] +# E2EE? = False so we don't pull the [encryption] extras. +_YES_NO = [False] + + +class TestMatrixHomeChannelClear: + """Blank home-room answer must clear MATRIX_HOME_ROOM (#12423).""" + + def test_blank_removes_existing_home_room(self, monkeypatch, tmp_path): + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + saved, removed = {}, [] + _patch_setup_io( + monkeypatch, + _PROMPTS_BLANK, + _YES_NO, + saved, + removed, + existing={"MATRIX_HOME_ROOM": "!oldRoomId:matrix.example.org"}, + ) + interactive_setup() + assert "MATRIX_HOME_ROOM" in removed + assert "MATRIX_HOME_ROOM" not in saved + + def test_blank_without_prior_home_still_attempts_remove(self, monkeypatch, tmp_path): + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + saved, removed = {}, [] + _patch_setup_io( + monkeypatch, _PROMPTS_BLANK, _YES_NO, saved, removed, existing={} + ) + interactive_setup() + assert removed.count("MATRIX_HOME_ROOM") == 1 + + def test_nonempty_saves_home_room(self, monkeypatch, tmp_path): + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + saved, removed = {}, [] + _patch_setup_io( + monkeypatch, _PROMPTS_NONEMPTY, _YES_NO, saved, removed, existing={} + ) + interactive_setup() + assert saved["MATRIX_HOME_ROOM"] == "!AbCdEfGhIjKlMn:matrix.example.org" + assert "MATRIX_HOME_ROOM" not in removed + + def test_whitespace_only_clears_home_room(self, monkeypatch, tmp_path): + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + saved, removed = {}, [] + _patch_setup_io( + monkeypatch, + _PROMPTS_WHITESPACE, + _YES_NO, + saved, + removed, + existing={"MATRIX_HOME_ROOM": "!oldRoomId:matrix.example.org"}, + ) + interactive_setup() + assert "MATRIX_HOME_ROOM" in removed + assert "MATRIX_HOME_ROOM" not in saved \ No newline at end of file diff --git a/tests/gateway/test_mattermost_plugin_setup.py b/tests/gateway/test_mattermost_plugin_setup.py new file mode 100644 index 000000000000..b50c5754137a --- /dev/null +++ b/tests/gateway/test_mattermost_plugin_setup.py @@ -0,0 +1,84 @@ +"""Tests for the Mattermost plugin's interactive_setup wizard home-channel flow. + +The interactive_setup wizard lazy-imports its CLI helpers from +``hermes_cli.config`` (get_env_value / save_env_value / remove_env_value) and +``hermes_cli.cli_output`` (prompt / prompt_yes_no / print_*); we patch those +source modules. Covers the home-channel clear-on-blank behavior added in +PR #58421 and extended in the follow-up. +""" +import hermes_cli.config as config_mod +import hermes_cli.cli_output as cli_output_mod +from plugins.platforms.mattermost.adapter import interactive_setup + + +def _patch_setup_io(monkeypatch, prompts, saved, removed, existing): + prompt_iter = iter(prompts) + monkeypatch.setattr(config_mod, "get_env_value", lambda key: existing.get(key, "")) + monkeypatch.setattr(config_mod, "save_env_value", lambda k, v: saved.update({k: v})) + + def _remove(key): + removed.append(key) + return existing.pop(key, None) is not None + + monkeypatch.setattr(config_mod, "remove_env_value", _remove) + monkeypatch.setattr(cli_output_mod, "prompt", lambda *_a, **_kw: next(prompt_iter)) + monkeypatch.setattr(cli_output_mod, "prompt_yes_no", lambda *_a, **_kw: False) + for name in ("print_header", "print_info", "print_success", "print_warning"): + monkeypatch.setattr(cli_output_mod, name, lambda *_a, **_kw: None) + + +# Mattermost prompts: server_url, bot_token (password), allowed_users, home_channel. +_PROMPTS_NONEMPTY = ["https://mm.example.com", "«redacted:mm-token»", "", "town-square-id"] +_PROMPTS_BLANK = ["https://mm.example.com", "«redacted:mm-token»", "", ""] +_PROMPTS_WHITESPACE = ["https://mm.example.com", "«redacted:mm-token»", "", " "] + + +class TestMattermostHomeChannelClear: + """Blank home-channel answer must clear MATTERMOST_HOME_CHANNEL (#12423).""" + + def test_blank_removes_existing_home_channel(self, monkeypatch, tmp_path): + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + saved, removed = {}, [] + _patch_setup_io( + monkeypatch, + _PROMPTS_BLANK, + saved, + removed, + existing={"MATTERMOST_HOME_CHANNEL": "old-channel-id"}, + ) + interactive_setup() + assert "MATTERMOST_HOME_CHANNEL" in removed + assert "MATTERMOST_HOME_CHANNEL" not in saved + + def test_blank_without_prior_home_still_attempts_remove(self, monkeypatch, tmp_path): + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + saved, removed = {}, [] + _patch_setup_io( + monkeypatch, _PROMPTS_BLANK, saved, removed, existing={} + ) + interactive_setup() + assert removed.count("MATTERMOST_HOME_CHANNEL") == 1 + + def test_nonempty_saves_home_channel(self, monkeypatch, tmp_path): + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + saved, removed = {}, [] + _patch_setup_io( + monkeypatch, _PROMPTS_NONEMPTY, saved, removed, existing={} + ) + interactive_setup() + assert saved["MATTERMOST_HOME_CHANNEL"] == "town-square-id" + assert "MATTERMOST_HOME_CHANNEL" not in removed + + def test_whitespace_only_clears_home_channel(self, monkeypatch, tmp_path): + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + saved, removed = {}, [] + _patch_setup_io( + monkeypatch, + _PROMPTS_WHITESPACE, + saved, + removed, + existing={"MATTERMOST_HOME_CHANNEL": "old-channel-id"}, + ) + interactive_setup() + assert "MATTERMOST_HOME_CHANNEL" in removed + assert "MATTERMOST_HOME_CHANNEL" not in saved \ No newline at end of file diff --git a/tests/gateway/test_setup_feishu.py b/tests/gateway/test_setup_feishu.py index bd1d341ea734..9229e34dc367 100644 --- a/tests/gateway/test_setup_feishu.py +++ b/tests/gateway/test_setup_feishu.py @@ -32,6 +32,7 @@ def _run_setup_feishu( prompt_responses = list(prompt_responses or [""]) saved_env = {} + removed_keys = [] def mock_save(name, value): saved_env[name] = value @@ -39,8 +40,19 @@ def _run_setup_feishu( def mock_get(name): return existing_env.get(name, "") + def mock_remove(name): + removed_keys.append(name) + if name in existing_env: + del existing_env[name] + return True + if name in saved_env: + del saved_env[name] + return True + return False + with patch("hermes_cli.config.save_env_value", side_effect=mock_save), \ patch("hermes_cli.config.get_env_value", side_effect=mock_get), \ + patch("hermes_cli.config.remove_env_value", side_effect=mock_remove), \ patch("hermes_cli.cli_output.prompt_yes_no", side_effect=prompt_yes_no_responses), \ patch("hermes_cli.setup.prompt_choice", side_effect=prompt_choice_responses), \ patch("hermes_cli.cli_output.prompt", side_effect=prompt_responses), \ @@ -54,7 +66,7 @@ def _run_setup_feishu( from plugins.platforms.feishu.adapter import interactive_setup interactive_setup() - return saved_env + return saved_env, removed_keys # --------------------------------------------------------------------------- @@ -65,7 +77,7 @@ class TestSetupFeishuQrPath: """Tests for the QR scan-to-create happy path.""" def test_qr_success_saves_core_credentials(self): - env = _run_setup_feishu( + env, _ = _run_setup_feishu( qr_result={ "app_id": "cli_test", "app_secret": "secret_test", @@ -85,7 +97,7 @@ class TestSetupFeishuQrPath: def test_qr_success_does_not_persist_bot_identity(self): """Bot identity is discovered at runtime by _hydrate_bot_identity — not persisted in env, so it stays fresh if the user renames the bot later.""" - env = _run_setup_feishu( + env, _ = _run_setup_feishu( qr_result={ "app_id": "cli_test", "app_secret": "secret_test", @@ -110,7 +122,7 @@ class TestSetupFeishuConnectionMode: """Connection mode: QR always websocket, manual path lets user choose.""" def test_qr_path_defaults_to_websocket(self): - env = _run_setup_feishu( + env, _ = _run_setup_feishu( qr_result={ "app_id": "cli_test", "app_secret": "s", "domain": "feishu", "open_id": None, "bot_name": None, "bot_open_id": None, @@ -122,7 +134,7 @@ class TestSetupFeishuConnectionMode: @patch("plugins.platforms.feishu.adapter.probe_bot", return_value=None) def test_manual_path_websocket(self, _mock_probe): - env = _run_setup_feishu( + env, _ = _run_setup_feishu( qr_result=None, prompt_choice_responses=[1, 0, 0, 0, 0], # method=manual, domain=feishu, connection=ws, dm=pairing, group=open prompt_responses=["cli_manual", "secret_manual", ""], # app_id, app_secret, home_channel @@ -131,7 +143,7 @@ class TestSetupFeishuConnectionMode: @patch("plugins.platforms.feishu.adapter.probe_bot", return_value=None) def test_manual_path_webhook(self, _mock_probe): - env = _run_setup_feishu( + env, _ = _run_setup_feishu( qr_result=None, prompt_choice_responses=[1, 0, 1, 0, 0], # method=manual, domain=feishu, connection=webhook, dm=pairing, group=open prompt_responses=["cli_manual", "secret_manual", ""], # app_id, app_secret, home_channel @@ -147,7 +159,7 @@ class TestSetupFeishuDmPolicy: """DM policy must use platform-scoped FEISHU_ALLOW_ALL_USERS, not the global flag.""" def _run_with_dm_choice(self, dm_choice_idx, prompt_responses=None): - return _run_setup_feishu( + env, _ = _run_setup_feishu( qr_result={ "app_id": "cli_test", "app_secret": "s", "domain": "feishu", "open_id": "ou_owner", "bot_name": None, "bot_open_id": None, @@ -156,6 +168,7 @@ class TestSetupFeishuDmPolicy: prompt_choice_responses=[0, dm_choice_idx, 0], # method=QR, dm=, group=open prompt_responses=prompt_responses or [""], ) + return env def test_pairing_sets_feishu_allow_all_false(self): env = self._run_with_dm_choice(0) @@ -190,7 +203,7 @@ class TestSetupFeishuDmPolicy: class TestSetupFeishuGroupPolicy: def test_open_with_mention(self): - env = _run_setup_feishu( + env, _ = _run_setup_feishu( qr_result={ "app_id": "cli_test", "app_secret": "s", "domain": "feishu", "open_id": None, "bot_name": None, "bot_open_id": None, @@ -202,7 +215,7 @@ class TestSetupFeishuGroupPolicy: assert env["FEISHU_GROUP_POLICY"] == "open" def test_disabled(self): - env = _run_setup_feishu( + env, _ = _run_setup_feishu( qr_result={ "app_id": "cli_test", "app_secret": "s", "domain": "feishu", "open_id": None, "bot_name": None, "bot_open_id": None, @@ -214,6 +227,70 @@ class TestSetupFeishuGroupPolicy: assert env["FEISHU_GROUP_POLICY"] == "disabled" +# --------------------------------------------------------------------------- +# Home channel (optional clear — Issue #12423) +# --------------------------------------------------------------------------- + +class TestSetupFeishuHomeChannel: + """Blank home-channel answer must clear FEISHU_HOME_CHANNEL.""" + + def test_blank_removes_existing_home_channel(self): + env, removed = _run_setup_feishu( + qr_result={ + "app_id": "cli_test", "app_secret": "s", "domain": "feishu", + "open_id": None, "bot_name": None, "bot_open_id": None, + }, + prompt_yes_no_responses=[True], + prompt_choice_responses=[0, 0, 0], + prompt_responses=[""], + existing_env={"FEISHU_HOME_CHANNEL": "chat_old"}, + ) + assert "FEISHU_HOME_CHANNEL" in removed + assert "FEISHU_HOME_CHANNEL" not in env + + def test_blank_without_prior_home_still_attempts_remove(self): + _, removed = _run_setup_feishu( + qr_result={ + "app_id": "cli_test", "app_secret": "s", "domain": "feishu", + "open_id": None, "bot_name": None, "bot_open_id": None, + }, + prompt_yes_no_responses=[True], + prompt_choice_responses=[0, 0, 0], + prompt_responses=[""], + existing_env={}, + ) + assert removed.count("FEISHU_HOME_CHANNEL") == 1 + + def test_nonempty_saves_home_channel(self): + env, removed = _run_setup_feishu( + qr_result={ + "app_id": "cli_test", "app_secret": "s", "domain": "feishu", + "open_id": None, "bot_name": None, "bot_open_id": None, + }, + prompt_yes_no_responses=[True], + prompt_choice_responses=[0, 0, 0], + prompt_responses=["oc_chat123"], + existing_env={}, + ) + assert env["FEISHU_HOME_CHANNEL"] == "oc_chat123" + assert "FEISHU_HOME_CHANNEL" not in removed + + def test_whitespace_only_clears_home_channel(self): + """Whitespace-only input should clear, not save.""" + env, removed = _run_setup_feishu( + qr_result={ + "app_id": "cli_test", "app_secret": "s", "domain": "feishu", + "open_id": None, "bot_name": None, "bot_open_id": None, + }, + prompt_yes_no_responses=[True], + prompt_choice_responses=[0, 0, 0], + prompt_responses=[" "], + existing_env={"FEISHU_HOME_CHANNEL": "chat_old"}, + ) + assert "FEISHU_HOME_CHANNEL" in removed + assert "FEISHU_HOME_CHANNEL" not in env + + # --------------------------------------------------------------------------- # Adapter integration: env vars → FeishuAdapterSettings # --------------------------------------------------------------------------- @@ -227,7 +304,7 @@ class TestSetupFeishuAdapterIntegration: def _make_env_from_setup(self, dm_idx=0, group_idx=0): """Run _setup_feishu via QR path and return the env vars it would write.""" - return _run_setup_feishu( + env, _ = _run_setup_feishu( qr_result={ "app_id": "cli_test_app", "app_secret": "test_secret_value", @@ -240,6 +317,7 @@ class TestSetupFeishuAdapterIntegration: prompt_choice_responses=[0, dm_idx, group_idx], # method=QR, dm, group prompt_responses=[""], ) + return env @patch.dict(os.environ, {}, clear=True) def test_qr_env_produces_valid_adapter_settings(self): diff --git a/tests/gateway/test_slack_plugin_setup.py b/tests/gateway/test_slack_plugin_setup.py index 1a1ac7eba6cb..494335312b70 100644 --- a/tests/gateway/test_slack_plugin_setup.py +++ b/tests/gateway/test_slack_plugin_setup.py @@ -3,20 +3,27 @@ These cover the home-channel save logic that previously lived in ``hermes_cli/setup.py::_setup_slack`` before the Slack adapter migrated to a bundled plugin (#41112). ``interactive_setup`` lazy-imports its CLI helpers -from ``hermes_cli.config`` (get_env_value / save_env_value) and -``hermes_cli.cli_output`` (prompt / prompt_yes_no / print_*), so we patch those -source modules. +from ``hermes_cli.config`` (get_env_value / save_env_value / remove_env_value) +and ``hermes_cli.cli_output`` (prompt / prompt_yes_no / print_*), so we patch +those source modules. """ import hermes_cli.config as config_mod import hermes_cli.cli_output as cli_output_mod from plugins.platforms.slack.adapter import interactive_setup -def _patch_setup_io(monkeypatch, prompts, saved): +def _patch_setup_io(monkeypatch, prompts, saved, removed, existing): """Wire interactive_setup's lazy-imported CLI helpers to test doubles.""" prompt_iter = iter(prompts) - monkeypatch.setattr(config_mod, "get_env_value", lambda key: "") + monkeypatch.setattr(config_mod, "get_env_value", lambda key: existing.get(key, "")) monkeypatch.setattr(config_mod, "save_env_value", lambda k, v: saved.update({k: v})) + + # Mirror remove_env_value's real semantics: True if removed, False if absent. + def _remove(key): + removed.append(key) + return existing.pop(key, None) is not None + + monkeypatch.setattr(config_mod, "remove_env_value", _remove) monkeypatch.setattr(cli_output_mod, "prompt", lambda *_a, **_kw: next(prompt_iter)) monkeypatch.setattr(cli_output_mod, "prompt_yes_no", lambda *_a, **_kw: False) for name in ("print_header", "print_info", "print_success", "print_warning"): @@ -29,29 +36,94 @@ def _patch_setup_io(monkeypatch, prompts, saved): def test_interactive_setup_saves_home_channel(monkeypatch, tmp_path): """interactive_setup() saves SLACK_HOME_CHANNEL when the user provides one.""" monkeypatch.setenv("HERMES_HOME", str(tmp_path)) - saved = {} + saved, removed = {}, [] # prompts: bot token, app token, allowed users (empty), home channel _patch_setup_io( monkeypatch, - ["xoxb-test-token", "xapp-test-token", "", "C01ABC2DE3F"], + ["«redacted:xox…»", "xapp-test-token", "", "C01ABC2DE3F"], saved, + removed, + existing={}, ) interactive_setup() assert saved.get("SLACK_HOME_CHANNEL") == "C01ABC2DE3F" + assert "SLACK_HOME_CHANNEL" not in removed def test_interactive_setup_home_channel_empty_not_saved(monkeypatch, tmp_path): """interactive_setup() does not save SLACK_HOME_CHANNEL when left blank.""" monkeypatch.setenv("HERMES_HOME", str(tmp_path)) - saved = {} + saved, removed = {}, [] _patch_setup_io( monkeypatch, - ["xoxb-test-token", "xapp-test-token", "", ""], + ["«redacted:xox…»", "xapp-test-token", "", ""], saved, + removed, + existing={}, ) interactive_setup() assert "SLACK_HOME_CHANNEL" not in saved + + +class TestSlackHomeChannelClear: + """Blank home-channel answer must clear SLACK_HOME_CHANNEL (#12423).""" + + def test_blank_removes_existing_home_channel(self, monkeypatch, tmp_path): + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + saved, removed = {}, [] + _patch_setup_io( + monkeypatch, + ["«redacted:xox…»", "xapp-test-token", "", ""], + saved, + removed, + existing={"SLACK_HOME_CHANNEL": "C01OLDHOMEXYZ"}, + ) + interactive_setup() + assert "SLACK_HOME_CHANNEL" in removed + assert "SLACK_HOME_CHANNEL" not in saved + + def test_blank_without_prior_home_still_attempts_remove(self, monkeypatch, tmp_path): + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + saved, removed = {}, [] + _patch_setup_io( + monkeypatch, + ["«redacted:xox…»", "xapp-test-token", "", ""], + saved, + removed, + existing={}, + ) + interactive_setup() + assert removed.count("SLACK_HOME_CHANNEL") == 1 + + def test_nonempty_saves_home_channel(self, monkeypatch, tmp_path): + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + saved, removed = {}, [] + _patch_setup_io( + monkeypatch, + ["«redacted:xox…»", "xapp-test-token", "", "C01ABC2DE3F"], + saved, + removed, + existing={}, + ) + interactive_setup() + assert saved["SLACK_HOME_CHANNEL"] == "C01ABC2DE3F" + assert "SLACK_HOME_CHANNEL" not in removed + + def test_whitespace_only_clears_home_channel(self, monkeypatch, tmp_path): + """Whitespace-only input should clear, not save.""" + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + saved, removed = {}, [] + _patch_setup_io( + monkeypatch, + ["«redacted:xox…»", "xapp-test-token", "", " "], + saved, + removed, + existing={"SLACK_HOME_CHANNEL": "C01OLDHOMEXYZ"}, + ) + interactive_setup() + assert "SLACK_HOME_CHANNEL" in removed + assert "SLACK_HOME_CHANNEL" not in saved \ No newline at end of file diff --git a/tests/gateway/test_wecom_plugin_setup.py b/tests/gateway/test_wecom_plugin_setup.py new file mode 100644 index 000000000000..ecbc52fbb9fa --- /dev/null +++ b/tests/gateway/test_wecom_plugin_setup.py @@ -0,0 +1,111 @@ +"""Tests for the WeCom plugin's interactive_setup wizard home-channel flow. + +The interactive_setup wizard lazy-imports its CLI helpers from +``hermes_cli.config`` (get_env_value / save_env_value / remove_env_value), +``hermes_cli.cli_output`` (prompt / prompt_yes_no / print_*), and +``hermes_cli.setup`` (prompt_choice); we patch each at its source module so +the QR scan / pip paths never fire. Covers the home-channel clear-on-blank +behavior added in the follow-up to PR #58421. +""" +import hermes_cli.config as config_mod +import hermes_cli.cli_output as cli_output_mod +import hermes_cli.setup as setup_mod +from plugins.platforms.wecom.adapter import interactive_setup + + +def _patch_setup_io(monkeypatch, prompts, choice_responses, saved, removed, existing): + prompt_iter = iter(prompts) + choice_iter = iter(choice_responses) + monkeypatch.setattr(config_mod, "get_env_value", lambda key: existing.get(key, "")) + monkeypatch.setattr(config_mod, "save_env_value", lambda k, v: saved.update({k: v})) + + def _remove(key): + removed.append(key) + return existing.pop(key, None) is not None + + monkeypatch.setattr(config_mod, "remove_env_value", _remove) + monkeypatch.setattr(cli_output_mod, "prompt", lambda *_a, **_kw: next(prompt_iter)) + monkeypatch.setattr(cli_output_mod, "prompt_yes_no", lambda *_a, **_kw: False) + monkeypatch.setattr(setup_mod, "prompt_choice", lambda *_a, **_kw: next(choice_iter)) + for name in ("print_header", "print_info", "print_success", "print_warning", "print_error"): + monkeypatch.setattr(cli_output_mod, name, lambda *_a, **_kw: None) + # Stub QR scan: returns None so the wizard falls through to manual entry. + import plugins.platforms.wecom.adapter as wecom_adapter_mod + monkeypatch.setattr(wecom_adapter_mod, "qr_scan_for_bot_info", lambda: None) + + +# WeCom prompts (after method_choice): bot_id, secret (password), allowed_users, home. +# choice_responses: 1 = "Enter Bot ID/Secret manually" so the QR path is skipped. +_PROMPTS_NONEMPTY = [ + "wecom-bot-id-123", # bot_id + "wecom-secret-abc", # secret + "", # allowed_users (empty -> triggers DM policy choice below) + "wecom-home-chat-id", # home chat ID +] +_PROMPTS_BLANK = [ + "wecom-bot-id-123", + "wecom-secret-abc", + "", + "", +] +_PROMPTS_WHITESPACE = [ + "wecom-bot-id-123", + "wecom-secret-abc", + "", + " ", +] +# 1 = method "Enter manually", 1 = DM policy "pairing" (skip the 4-way choice path). +_CHOICES = [1, 1] + + +class TestWeComHomeChannelClear: + """Blank home-channel answer must clear WECOM_HOME_CHANNEL (#12423).""" + + def test_blank_removes_existing_home_channel(self, monkeypatch, tmp_path): + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + saved, removed = {}, [] + _patch_setup_io( + monkeypatch, + _PROMPTS_BLANK, + _CHOICES, + saved, + removed, + existing={"WECOM_HOME_CHANNEL": "old-wecom-chat-id"}, + ) + interactive_setup() + assert "WECOM_HOME_CHANNEL" in removed + assert "WECOM_HOME_CHANNEL" not in saved + + def test_blank_without_prior_home_still_attempts_remove(self, monkeypatch, tmp_path): + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + saved, removed = {}, [] + _patch_setup_io( + monkeypatch, _PROMPTS_BLANK, _CHOICES, saved, removed, existing={} + ) + interactive_setup() + assert removed.count("WECOM_HOME_CHANNEL") == 1 + + def test_nonempty_saves_home_channel(self, monkeypatch, tmp_path): + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + saved, removed = {}, [] + _patch_setup_io( + monkeypatch, _PROMPTS_NONEMPTY, _CHOICES, saved, removed, existing={} + ) + interactive_setup() + assert saved["WECOM_HOME_CHANNEL"] == "wecom-home-chat-id" + assert "WECOM_HOME_CHANNEL" not in removed + + def test_whitespace_only_clears_home_channel(self, monkeypatch, tmp_path): + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + saved, removed = {}, [] + _patch_setup_io( + monkeypatch, + _PROMPTS_WHITESPACE, + _CHOICES, + saved, + removed, + existing={"WECOM_HOME_CHANNEL": "old-wecom-chat-id"}, + ) + interactive_setup() + assert "WECOM_HOME_CHANNEL" in removed + assert "WECOM_HOME_CHANNEL" not in saved \ No newline at end of file diff --git a/tests/gateway/test_whatsapp_plugin_setup.py b/tests/gateway/test_whatsapp_plugin_setup.py new file mode 100644 index 000000000000..48e6ba97079c --- /dev/null +++ b/tests/gateway/test_whatsapp_plugin_setup.py @@ -0,0 +1,91 @@ +"""Tests for the WhatsApp plugin's interactive_setup wizard home-channel flow. + +The interactive_setup wizard lazy-imports its CLI helpers from +``hermes_cli.config`` (get_env_value / save_env_value / remove_env_value) and +``hermes_cli.cli_output`` (prompt / prompt_yes_no / print_*); we patch those +source modules. Covers the home-channel clear-on-blank behavior added in +PR #58421 and extended in the follow-up. +""" +import hermes_cli.config as config_mod +import hermes_cli.cli_output as cli_output_mod +from plugins.platforms.whatsapp.adapter import interactive_setup + + +def _patch_setup_io(monkeypatch, prompts, yes_no_responses, saved, removed, existing): + prompt_iter = iter(prompts) + yes_no_iter = iter(yes_no_responses) + monkeypatch.setattr(config_mod, "get_env_value", lambda key: existing.get(key, "")) + monkeypatch.setattr(config_mod, "save_env_value", lambda k, v: saved.update({k: v})) + + def _remove(key): + removed.append(key) + return existing.pop(key, None) is not None + + monkeypatch.setattr(config_mod, "remove_env_value", _remove) + monkeypatch.setattr(cli_output_mod, "prompt", lambda *_a, **_kw: next(prompt_iter)) + monkeypatch.setattr( + cli_output_mod, "prompt_yes_no", lambda *_a, **_kw: next(yes_no_iter) + ) + for name in ("print_header", "print_info", "print_success", "print_warning"): + monkeypatch.setattr(cli_output_mod, name, lambda *_a, **_kw: None) + + +# WhatsApp prompts (after the Enable? prompt_yes_no): allowed_users, home_channel. +# Enable? = True so we don't return early. +_PROMPTS_NONEMPTY = ["", "12025550100@c.us"] +_PROMPTS_BLANK = ["", ""] +_PROMPTS_WHITESPACE = ["", " "] +_YES_NO = [True] # Enable WhatsApp? -> True + + +class TestWhatsAppHomeChannelClear: + """Blank home-channel answer must clear WHATSAPP_HOME_CHANNEL (#12423).""" + + def test_blank_removes_existing_home_channel(self, monkeypatch, tmp_path): + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + saved, removed = {}, [] + _patch_setup_io( + monkeypatch, + _PROMPTS_BLANK, + _YES_NO, + saved, + removed, + existing={"WHATSAPP_HOME_CHANNEL": "12025550100@c.us"}, + ) + interactive_setup() + assert "WHATSAPP_HOME_CHANNEL" in removed + assert "WHATSAPP_HOME_CHANNEL" not in saved + + def test_blank_without_prior_home_still_attempts_remove(self, monkeypatch, tmp_path): + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + saved, removed = {}, [] + _patch_setup_io( + monkeypatch, _PROMPTS_BLANK, _YES_NO, saved, removed, existing={} + ) + interactive_setup() + assert removed.count("WHATSAPP_HOME_CHANNEL") == 1 + + def test_nonempty_saves_home_channel(self, monkeypatch, tmp_path): + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + saved, removed = {}, [] + _patch_setup_io( + monkeypatch, _PROMPTS_NONEMPTY, _YES_NO, saved, removed, existing={} + ) + interactive_setup() + assert saved["WHATSAPP_HOME_CHANNEL"] == "12025550100@c.us" + assert "WHATSAPP_HOME_CHANNEL" not in removed + + def test_whitespace_only_clears_home_channel(self, monkeypatch, tmp_path): + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + saved, removed = {}, [] + _patch_setup_io( + monkeypatch, + _PROMPTS_WHITESPACE, + _YES_NO, + saved, + removed, + existing={"WHATSAPP_HOME_CHANNEL": "12025550100@c.us"}, + ) + interactive_setup() + assert "WHATSAPP_HOME_CHANNEL" in removed + assert "WHATSAPP_HOME_CHANNEL" not in saved \ No newline at end of file