mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-04-29 15:31:38 +08:00
Compare commits
4 Commits
skill/gith
...
hermes/buc
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b49a9bff7a | ||
|
|
3ca0467040 | ||
|
|
627a6d1435 | ||
|
|
a915d66c72 |
176
tests/tools/test_budget_config.py
Normal file
176
tests/tools/test_budget_config.py
Normal file
@@ -0,0 +1,176 @@
|
||||
"""Unit tests for tools/budget_config.py.
|
||||
|
||||
Covers default values, resolve_threshold() priority chain
|
||||
(pinned > tool_overrides > registry > default), immutability,
|
||||
and the PINNED_THRESHOLDS escape-hatch for read_file.
|
||||
"""
|
||||
|
||||
import dataclasses
|
||||
import math
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from tools.budget_config import (
|
||||
DEFAULT_BUDGET,
|
||||
DEFAULT_PREVIEW_SIZE_CHARS,
|
||||
DEFAULT_RESULT_SIZE_CHARS,
|
||||
DEFAULT_TURN_BUDGET_CHARS,
|
||||
PINNED_THRESHOLDS,
|
||||
BudgetConfig,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Module-level constants
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestModuleConstants:
|
||||
"""Verify documented default values haven't drifted."""
|
||||
|
||||
def test_default_result_size(self):
|
||||
assert DEFAULT_RESULT_SIZE_CHARS == 100_000
|
||||
|
||||
def test_default_turn_budget(self):
|
||||
assert DEFAULT_TURN_BUDGET_CHARS == 200_000
|
||||
|
||||
def test_default_preview_size(self):
|
||||
assert DEFAULT_PREVIEW_SIZE_CHARS == 1_500
|
||||
|
||||
|
||||
class TestPinnedThresholds:
|
||||
"""PINNED_THRESHOLDS – tools whose values must never be overridden."""
|
||||
|
||||
def test_read_file_is_inf(self):
|
||||
assert PINNED_THRESHOLDS["read_file"] == float("inf")
|
||||
assert math.isinf(PINNED_THRESHOLDS["read_file"])
|
||||
|
||||
def test_pinned_is_not_empty(self):
|
||||
assert len(PINNED_THRESHOLDS) >= 1
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# BudgetConfig defaults
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestBudgetConfigDefaults:
|
||||
"""BudgetConfig() should match the module-level defaults exactly."""
|
||||
|
||||
def test_default_result_size(self):
|
||||
cfg = BudgetConfig()
|
||||
assert cfg.default_result_size == DEFAULT_RESULT_SIZE_CHARS
|
||||
|
||||
def test_default_turn_budget(self):
|
||||
cfg = BudgetConfig()
|
||||
assert cfg.turn_budget == DEFAULT_TURN_BUDGET_CHARS
|
||||
|
||||
def test_default_preview_size(self):
|
||||
cfg = BudgetConfig()
|
||||
assert cfg.preview_size == DEFAULT_PREVIEW_SIZE_CHARS
|
||||
|
||||
def test_default_tool_overrides_empty(self):
|
||||
cfg = BudgetConfig()
|
||||
assert cfg.tool_overrides == {}
|
||||
|
||||
def test_default_budget_singleton_matches(self):
|
||||
"""DEFAULT_BUDGET should equal a freshly constructed BudgetConfig."""
|
||||
assert DEFAULT_BUDGET == BudgetConfig()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Immutability (frozen=True)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestBudgetConfigFrozen:
|
||||
"""Frozen dataclass must reject attribute mutation."""
|
||||
|
||||
def test_cannot_set_default_result_size(self):
|
||||
cfg = BudgetConfig()
|
||||
with pytest.raises(dataclasses.FrozenInstanceError):
|
||||
cfg.default_result_size = 999
|
||||
|
||||
def test_cannot_set_turn_budget(self):
|
||||
cfg = BudgetConfig()
|
||||
with pytest.raises(dataclasses.FrozenInstanceError):
|
||||
cfg.turn_budget = 999
|
||||
|
||||
def test_cannot_set_preview_size(self):
|
||||
cfg = BudgetConfig()
|
||||
with pytest.raises(dataclasses.FrozenInstanceError):
|
||||
cfg.preview_size = 999
|
||||
|
||||
def test_cannot_set_tool_overrides(self):
|
||||
cfg = BudgetConfig()
|
||||
with pytest.raises(dataclasses.FrozenInstanceError):
|
||||
cfg.tool_overrides = {"foo": 1}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Custom construction
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestBudgetConfigCustom:
|
||||
"""BudgetConfig can be created with non-default values."""
|
||||
|
||||
def test_custom_values(self):
|
||||
cfg = BudgetConfig(
|
||||
default_result_size=50_000,
|
||||
turn_budget=100_000,
|
||||
preview_size=500,
|
||||
tool_overrides={"my_tool": 42},
|
||||
)
|
||||
assert cfg.default_result_size == 50_000
|
||||
assert cfg.turn_budget == 100_000
|
||||
assert cfg.preview_size == 500
|
||||
assert cfg.tool_overrides == {"my_tool": 42}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# resolve_threshold() priority chain
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestResolveThreshold:
|
||||
"""Priority: pinned > tool_overrides > registry > default."""
|
||||
|
||||
def test_pinned_wins_over_override(self):
|
||||
"""Even if tool_overrides contains read_file, pinned value wins."""
|
||||
cfg = BudgetConfig(tool_overrides={"read_file": 1})
|
||||
result = cfg.resolve_threshold("read_file")
|
||||
assert result == float("inf")
|
||||
|
||||
def test_tool_override_wins_over_default(self):
|
||||
"""tool_overrides should be returned before falling back to registry."""
|
||||
cfg = BudgetConfig(tool_overrides={"my_tool": 42})
|
||||
result = cfg.resolve_threshold("my_tool")
|
||||
assert result == 42
|
||||
|
||||
@patch("tools.registry.registry")
|
||||
def test_falls_back_to_registry(self, mock_registry):
|
||||
"""When not pinned and not in overrides, delegate to registry."""
|
||||
mock_registry.get_max_result_size.return_value = 77_777
|
||||
cfg = BudgetConfig()
|
||||
result = cfg.resolve_threshold("some_tool")
|
||||
mock_registry.get_max_result_size.assert_called_once_with(
|
||||
"some_tool", default=DEFAULT_RESULT_SIZE_CHARS
|
||||
)
|
||||
assert result == 77_777
|
||||
|
||||
@patch("tools.registry.registry")
|
||||
def test_registry_receives_custom_default(self, mock_registry):
|
||||
"""Custom default_result_size flows through to registry call."""
|
||||
mock_registry.get_max_result_size.return_value = 50_000
|
||||
cfg = BudgetConfig(default_result_size=50_000)
|
||||
cfg.resolve_threshold("unknown_tool")
|
||||
mock_registry.get_max_result_size.assert_called_once_with(
|
||||
"unknown_tool", default=50_000
|
||||
)
|
||||
|
||||
def test_pinned_read_file_returns_inf(self):
|
||||
"""Canonical case: read_file must always return inf."""
|
||||
cfg = BudgetConfig()
|
||||
assert cfg.resolve_threshold("read_file") == float("inf")
|
||||
287
tests/tools/test_tool_backend_helpers.py
Normal file
287
tests/tools/test_tool_backend_helpers.py
Normal file
@@ -0,0 +1,287 @@
|
||||
"""Unit tests for tools/tool_backend_helpers.py.
|
||||
|
||||
Tests cover:
|
||||
- managed_nous_tools_enabled() feature flag
|
||||
- normalize_browser_cloud_provider() coercion
|
||||
- coerce_modal_mode() / normalize_modal_mode() validation
|
||||
- has_direct_modal_credentials() detection
|
||||
- resolve_modal_backend_state() backend selection matrix
|
||||
- resolve_openai_audio_api_key() priority chain
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from tools.tool_backend_helpers import (
|
||||
coerce_modal_mode,
|
||||
has_direct_modal_credentials,
|
||||
managed_nous_tools_enabled,
|
||||
normalize_browser_cloud_provider,
|
||||
normalize_modal_mode,
|
||||
resolve_modal_backend_state,
|
||||
resolve_openai_audio_api_key,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# managed_nous_tools_enabled
|
||||
# ---------------------------------------------------------------------------
|
||||
class TestManagedNousToolsEnabled:
|
||||
"""Feature flag driven by HERMES_ENABLE_NOUS_MANAGED_TOOLS."""
|
||||
|
||||
def test_disabled_by_default(self, monkeypatch):
|
||||
monkeypatch.delenv("HERMES_ENABLE_NOUS_MANAGED_TOOLS", raising=False)
|
||||
assert managed_nous_tools_enabled() is False
|
||||
|
||||
@pytest.mark.parametrize("val", ["1", "true", "True", "yes"])
|
||||
def test_enabled_when_truthy(self, monkeypatch, val):
|
||||
monkeypatch.setenv("HERMES_ENABLE_NOUS_MANAGED_TOOLS", val)
|
||||
assert managed_nous_tools_enabled() is True
|
||||
|
||||
@pytest.mark.parametrize("val", ["0", "false", "no", ""])
|
||||
def test_disabled_when_falsy(self, monkeypatch, val):
|
||||
monkeypatch.setenv("HERMES_ENABLE_NOUS_MANAGED_TOOLS", val)
|
||||
assert managed_nous_tools_enabled() is False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# normalize_browser_cloud_provider
|
||||
# ---------------------------------------------------------------------------
|
||||
class TestNormalizeBrowserCloudProvider:
|
||||
"""Coerce arbitrary input to a lowercase browser provider key."""
|
||||
|
||||
def test_none_returns_default(self):
|
||||
assert normalize_browser_cloud_provider(None) == "local"
|
||||
|
||||
def test_empty_string_returns_default(self):
|
||||
assert normalize_browser_cloud_provider("") == "local"
|
||||
|
||||
def test_whitespace_only_returns_default(self):
|
||||
assert normalize_browser_cloud_provider(" ") == "local"
|
||||
|
||||
def test_known_provider_normalized(self):
|
||||
assert normalize_browser_cloud_provider("BrowserBase") == "browserbase"
|
||||
|
||||
def test_strips_whitespace(self):
|
||||
assert normalize_browser_cloud_provider(" Local ") == "local"
|
||||
|
||||
def test_integer_coerced(self):
|
||||
result = normalize_browser_cloud_provider(42)
|
||||
assert isinstance(result, str)
|
||||
assert result == "42"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# coerce_modal_mode / normalize_modal_mode
|
||||
# ---------------------------------------------------------------------------
|
||||
class TestCoerceModalMode:
|
||||
"""Validate and coerce the requested modal execution mode."""
|
||||
|
||||
@pytest.mark.parametrize("value", ["auto", "direct", "managed"])
|
||||
def test_valid_modes_passthrough(self, value):
|
||||
assert coerce_modal_mode(value) == value
|
||||
|
||||
def test_none_returns_auto(self):
|
||||
assert coerce_modal_mode(None) == "auto"
|
||||
|
||||
def test_empty_string_returns_auto(self):
|
||||
assert coerce_modal_mode("") == "auto"
|
||||
|
||||
def test_whitespace_only_returns_auto(self):
|
||||
assert coerce_modal_mode(" ") == "auto"
|
||||
|
||||
def test_uppercase_normalized(self):
|
||||
assert coerce_modal_mode("DIRECT") == "direct"
|
||||
|
||||
def test_mixed_case_normalized(self):
|
||||
assert coerce_modal_mode("Managed") == "managed"
|
||||
|
||||
def test_invalid_mode_falls_back_to_auto(self):
|
||||
assert coerce_modal_mode("invalid") == "auto"
|
||||
assert coerce_modal_mode("cloud") == "auto"
|
||||
|
||||
def test_strips_whitespace(self):
|
||||
assert coerce_modal_mode(" managed ") == "managed"
|
||||
|
||||
|
||||
class TestNormalizeModalMode:
|
||||
"""normalize_modal_mode is an alias for coerce_modal_mode."""
|
||||
|
||||
def test_delegates_to_coerce(self):
|
||||
assert normalize_modal_mode("direct") == coerce_modal_mode("direct")
|
||||
assert normalize_modal_mode(None) == coerce_modal_mode(None)
|
||||
assert normalize_modal_mode("bogus") == coerce_modal_mode("bogus")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# has_direct_modal_credentials
|
||||
# ---------------------------------------------------------------------------
|
||||
class TestHasDirectModalCredentials:
|
||||
"""Detect Modal credentials via env vars or config file."""
|
||||
|
||||
def test_no_env_no_file(self, monkeypatch, tmp_path):
|
||||
monkeypatch.delenv("MODAL_TOKEN_ID", raising=False)
|
||||
monkeypatch.delenv("MODAL_TOKEN_SECRET", raising=False)
|
||||
with patch.object(Path, "home", return_value=tmp_path):
|
||||
assert has_direct_modal_credentials() is False
|
||||
|
||||
def test_both_env_vars_set(self, monkeypatch, tmp_path):
|
||||
monkeypatch.setenv("MODAL_TOKEN_ID", "id-123")
|
||||
monkeypatch.setenv("MODAL_TOKEN_SECRET", "sec-456")
|
||||
with patch.object(Path, "home", return_value=tmp_path):
|
||||
assert has_direct_modal_credentials() is True
|
||||
|
||||
def test_only_token_id_not_enough(self, monkeypatch, tmp_path):
|
||||
monkeypatch.setenv("MODAL_TOKEN_ID", "id-123")
|
||||
monkeypatch.delenv("MODAL_TOKEN_SECRET", raising=False)
|
||||
with patch.object(Path, "home", return_value=tmp_path):
|
||||
assert has_direct_modal_credentials() is False
|
||||
|
||||
def test_only_token_secret_not_enough(self, monkeypatch, tmp_path):
|
||||
monkeypatch.delenv("MODAL_TOKEN_ID", raising=False)
|
||||
monkeypatch.setenv("MODAL_TOKEN_SECRET", "sec-456")
|
||||
with patch.object(Path, "home", return_value=tmp_path):
|
||||
assert has_direct_modal_credentials() is False
|
||||
|
||||
def test_config_file_present(self, monkeypatch, tmp_path):
|
||||
monkeypatch.delenv("MODAL_TOKEN_ID", raising=False)
|
||||
monkeypatch.delenv("MODAL_TOKEN_SECRET", raising=False)
|
||||
(tmp_path / ".modal.toml").touch()
|
||||
with patch.object(Path, "home", return_value=tmp_path):
|
||||
assert has_direct_modal_credentials() is True
|
||||
|
||||
def test_env_vars_take_priority_over_file(self, monkeypatch, tmp_path):
|
||||
monkeypatch.setenv("MODAL_TOKEN_ID", "id-123")
|
||||
monkeypatch.setenv("MODAL_TOKEN_SECRET", "sec-456")
|
||||
(tmp_path / ".modal.toml").touch()
|
||||
with patch.object(Path, "home", return_value=tmp_path):
|
||||
assert has_direct_modal_credentials() is True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# resolve_modal_backend_state
|
||||
# ---------------------------------------------------------------------------
|
||||
class TestResolveModalBackendState:
|
||||
"""Full matrix of direct vs managed Modal backend selection."""
|
||||
|
||||
@staticmethod
|
||||
def _resolve(monkeypatch, mode, *, has_direct, managed_ready, nous_enabled=False):
|
||||
"""Helper to call resolve_modal_backend_state with feature flag control."""
|
||||
if nous_enabled:
|
||||
monkeypatch.setenv("HERMES_ENABLE_NOUS_MANAGED_TOOLS", "1")
|
||||
else:
|
||||
monkeypatch.setenv("HERMES_ENABLE_NOUS_MANAGED_TOOLS", "")
|
||||
return resolve_modal_backend_state(
|
||||
mode, has_direct=has_direct, managed_ready=managed_ready
|
||||
)
|
||||
|
||||
# --- auto mode ---
|
||||
|
||||
def test_auto_prefers_managed_when_available(self, monkeypatch):
|
||||
result = self._resolve(monkeypatch, "auto", has_direct=True, managed_ready=True, nous_enabled=True)
|
||||
assert result["selected_backend"] == "managed"
|
||||
|
||||
def test_auto_falls_back_to_direct(self, monkeypatch):
|
||||
result = self._resolve(monkeypatch, "auto", has_direct=True, managed_ready=False, nous_enabled=True)
|
||||
assert result["selected_backend"] == "direct"
|
||||
|
||||
def test_auto_no_backends_available(self, monkeypatch):
|
||||
result = self._resolve(monkeypatch, "auto", has_direct=False, managed_ready=False)
|
||||
assert result["selected_backend"] is None
|
||||
|
||||
def test_auto_managed_ready_but_nous_disabled(self, monkeypatch):
|
||||
result = self._resolve(monkeypatch, "auto", has_direct=True, managed_ready=True, nous_enabled=False)
|
||||
assert result["selected_backend"] == "direct"
|
||||
|
||||
def test_auto_nothing_when_only_managed_and_nous_disabled(self, monkeypatch):
|
||||
result = self._resolve(monkeypatch, "auto", has_direct=False, managed_ready=True, nous_enabled=False)
|
||||
assert result["selected_backend"] is None
|
||||
|
||||
# --- direct mode ---
|
||||
|
||||
def test_direct_selects_direct_when_available(self, monkeypatch):
|
||||
result = self._resolve(monkeypatch, "direct", has_direct=True, managed_ready=True, nous_enabled=True)
|
||||
assert result["selected_backend"] == "direct"
|
||||
|
||||
def test_direct_none_when_no_credentials(self, monkeypatch):
|
||||
result = self._resolve(monkeypatch, "direct", has_direct=False, managed_ready=True, nous_enabled=True)
|
||||
assert result["selected_backend"] is None
|
||||
|
||||
# --- managed mode ---
|
||||
|
||||
def test_managed_selects_managed_when_ready_and_enabled(self, monkeypatch):
|
||||
result = self._resolve(monkeypatch, "managed", has_direct=True, managed_ready=True, nous_enabled=True)
|
||||
assert result["selected_backend"] == "managed"
|
||||
|
||||
def test_managed_none_when_not_ready(self, monkeypatch):
|
||||
result = self._resolve(monkeypatch, "managed", has_direct=True, managed_ready=False, nous_enabled=True)
|
||||
assert result["selected_backend"] is None
|
||||
|
||||
def test_managed_blocked_when_nous_disabled(self, monkeypatch):
|
||||
result = self._resolve(monkeypatch, "managed", has_direct=True, managed_ready=True, nous_enabled=False)
|
||||
assert result["selected_backend"] is None
|
||||
assert result["managed_mode_blocked"] is True
|
||||
|
||||
# --- return structure ---
|
||||
|
||||
def test_return_dict_keys(self, monkeypatch):
|
||||
result = self._resolve(monkeypatch, "auto", has_direct=True, managed_ready=False)
|
||||
expected_keys = {
|
||||
"requested_mode",
|
||||
"mode",
|
||||
"has_direct",
|
||||
"managed_ready",
|
||||
"managed_mode_blocked",
|
||||
"selected_backend",
|
||||
}
|
||||
assert set(result.keys()) == expected_keys
|
||||
|
||||
def test_passthrough_flags(self, monkeypatch):
|
||||
result = self._resolve(monkeypatch, "direct", has_direct=True, managed_ready=False)
|
||||
assert result["requested_mode"] == "direct"
|
||||
assert result["mode"] == "direct"
|
||||
assert result["has_direct"] is True
|
||||
assert result["managed_ready"] is False
|
||||
|
||||
# --- invalid mode falls back to auto ---
|
||||
|
||||
def test_invalid_mode_treated_as_auto(self, monkeypatch):
|
||||
result = self._resolve(monkeypatch, "bogus", has_direct=True, managed_ready=False)
|
||||
assert result["requested_mode"] == "auto"
|
||||
assert result["mode"] == "auto"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# resolve_openai_audio_api_key
|
||||
# ---------------------------------------------------------------------------
|
||||
class TestResolveOpenaiAudioApiKey:
|
||||
"""Priority: VOICE_TOOLS_OPENAI_KEY > OPENAI_API_KEY."""
|
||||
|
||||
def test_voice_key_preferred(self, monkeypatch):
|
||||
monkeypatch.setenv("VOICE_TOOLS_OPENAI_KEY", "voice-key")
|
||||
monkeypatch.setenv("OPENAI_API_KEY", "general-key")
|
||||
assert resolve_openai_audio_api_key() == "voice-key"
|
||||
|
||||
def test_falls_back_to_openai_key(self, monkeypatch):
|
||||
monkeypatch.delenv("VOICE_TOOLS_OPENAI_KEY", raising=False)
|
||||
monkeypatch.setenv("OPENAI_API_KEY", "general-key")
|
||||
assert resolve_openai_audio_api_key() == "general-key"
|
||||
|
||||
def test_empty_voice_key_falls_back(self, monkeypatch):
|
||||
monkeypatch.setenv("VOICE_TOOLS_OPENAI_KEY", "")
|
||||
monkeypatch.setenv("OPENAI_API_KEY", "general-key")
|
||||
assert resolve_openai_audio_api_key() == "general-key"
|
||||
|
||||
def test_no_keys_returns_empty(self, monkeypatch):
|
||||
monkeypatch.delenv("VOICE_TOOLS_OPENAI_KEY", raising=False)
|
||||
monkeypatch.delenv("OPENAI_API_KEY", raising=False)
|
||||
assert resolve_openai_audio_api_key() == ""
|
||||
|
||||
def test_strips_whitespace(self, monkeypatch):
|
||||
monkeypatch.setenv("VOICE_TOOLS_OPENAI_KEY", " voice-key ")
|
||||
monkeypatch.delenv("OPENAI_API_KEY", raising=False)
|
||||
assert resolve_openai_audio_api_key() == "voice-key"
|
||||
@@ -16,7 +16,7 @@ This page is the top-level map of Hermes Agent internals. Use it to orient yours
|
||||
│ │
|
||||
│ CLI (cli.py) Gateway (gateway/run.py) ACP (acp_adapter/) │
|
||||
│ Batch Runner API Server Python Library │
|
||||
└──────────┬──────────────┬───────────────────────┬────────────────────┘
|
||||
└──────────┬──────────────┬───────────────────────┬───────────────────┘
|
||||
│ │ │
|
||||
▼ ▼ ▼
|
||||
┌─────────────────────────────────────────────────────────────────────┐
|
||||
|
||||
@@ -23,7 +23,7 @@ For the full voice feature set — including CLI microphone mode, spoken replies
|
||||
| Email | — | ✅ | ✅ | ✅ | — | — | — |
|
||||
| Home Assistant | — | — | — | — | — | — | — |
|
||||
| Mattermost | ✅ | ✅ | ✅ | ✅ | — | ✅ | ✅ |
|
||||
| Matrix | ✅ | ✅ | ✅ | ✅ | — | ✅ | ✅ |
|
||||
| Matrix | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||
| DingTalk | — | — | — | — | — | ✅ | ✅ |
|
||||
| Feishu/Lark | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||
| WeCom | ✅ | ✅ | ✅ | — | — | ✅ | ✅ |
|
||||
|
||||
Reference in New Issue
Block a user