mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-21 10:22:18 +00:00
refactor(gateway): migrate slack/dingtalk/whatsapp/matrix/feishu/telegram/wecom/email/sms adapters to bundled plugins
Salvage of PR #41284 onto current main. Relocates the last 9 inline messaging adapters (+ satellites: telegram_network, feishu_comment/_rules/meeting_invite, wecom_crypto, wecom_callback) from gateway/platforms/ into self-contained bundled plugins under plugins/platforms/<x>/, discovered via the platform registry. Strips the per-platform core touchpoints from gateway/run.py, gateway/config.py, hermes_cli/gateway.py, hermes_cli/setup.py, and tools/send_message_tool.py. Carries forward the migration fixes (explicit enabled:false honored, get_connected_platforms forces discovery, plugin is_connected via gateway.get_env_value, logs --component gateway matches plugins.platforms.*, matrix hidden on Windows). Additionally ports config keys main added since the PR base: the matrix plugin's _apply_yaml_config now also covers allowed_users, ignore_user_patterns, process_notices, and session_scope (the inline gateway/config.py matrix block gained these in the 1340 commits the PR sat open; they would otherwise have been silently dropped on deletion).
This commit is contained in:
parent
2ab09a6c50
commit
5600105478
124 changed files with 3643 additions and 2579 deletions
|
|
@ -463,23 +463,15 @@ _PLATFORM_CONNECTED_CHECKERS: dict[Platform, Callable[[PlatformConfig], bool]] =
|
|||
Platform.WEIXIN: lambda cfg: bool(
|
||||
cfg.extra.get("account_id") and (cfg.token or cfg.extra.get("token"))
|
||||
),
|
||||
Platform.WHATSAPP: lambda cfg: True, # bridge handles auth
|
||||
Platform.WHATSAPP_CLOUD: lambda cfg: bool(
|
||||
cfg.extra.get("phone_number_id") and cfg.extra.get("access_token")
|
||||
),
|
||||
Platform.SIGNAL: lambda cfg: bool(cfg.extra.get("http_url")),
|
||||
Platform.EMAIL: lambda cfg: bool(cfg.extra.get("address")),
|
||||
Platform.SMS: lambda cfg: bool(os.getenv("TWILIO_ACCOUNT_SID")),
|
||||
Platform.API_SERVER: lambda cfg: True,
|
||||
Platform.WEBHOOK: lambda cfg: True,
|
||||
Platform.MSGRAPH_WEBHOOK: lambda cfg: bool(
|
||||
str(cfg.extra.get("client_state") or "").strip()
|
||||
),
|
||||
Platform.FEISHU: lambda cfg: bool(cfg.extra.get("app_id")),
|
||||
Platform.WECOM: lambda cfg: bool(cfg.extra.get("bot_id")),
|
||||
Platform.WECOM_CALLBACK: lambda cfg: bool(
|
||||
cfg.extra.get("corp_id") or cfg.extra.get("apps")
|
||||
),
|
||||
Platform.BLUEBUBBLES: lambda cfg: bool(
|
||||
cfg.extra.get("server_url") and cfg.extra.get("password")
|
||||
),
|
||||
|
|
@ -489,10 +481,6 @@ _PLATFORM_CONNECTED_CHECKERS: dict[Platform, Callable[[PlatformConfig], bool]] =
|
|||
Platform.YUANBAO: lambda cfg: bool(
|
||||
cfg.extra.get("app_id") and cfg.extra.get("app_secret")
|
||||
),
|
||||
Platform.DINGTALK: lambda cfg: bool(
|
||||
(cfg.extra.get("client_id") or os.getenv("DINGTALK_CLIENT_ID"))
|
||||
and (cfg.extra.get("client_secret") or os.getenv("DINGTALK_CLIENT_SECRET"))
|
||||
),
|
||||
# Relay dials OUT to a connector; it is "connected" once an endpoint URL is
|
||||
# configured (extra["relay_url"] or extra["url"]). The capability descriptor
|
||||
# is negotiated at handshake time, so the URL is the only config-level
|
||||
|
|
@ -594,9 +582,17 @@ class GatewayConfig:
|
|||
if checker is not None:
|
||||
return checker(config)
|
||||
|
||||
# Plugin-registered platforms
|
||||
# Plugin-registered platforms. Force plugin discovery first so this
|
||||
# works even when GatewayConfig is constructed directly (e.g. in tests
|
||||
# or callers that bypass load_gateway_config(), which is what triggers
|
||||
# discovery in the normal path). discover_plugins() is idempotent.
|
||||
try:
|
||||
from gateway.platform_registry import platform_registry
|
||||
try:
|
||||
from hermes_cli.plugins import discover_plugins
|
||||
discover_plugins()
|
||||
except Exception:
|
||||
pass
|
||||
entry = platform_registry.get(platform.value)
|
||||
if entry:
|
||||
if entry.is_connected is not None:
|
||||
|
|
@ -1026,7 +1022,11 @@ def load_gateway_config() -> GatewayConfig:
|
|||
plat_data, extra = _ensure_platform_extra_dict(platforms_data, plat.value)
|
||||
if enabled_was_explicit:
|
||||
plat_data["enabled"] = platform_cfg["enabled"]
|
||||
if plat == Platform.SLACK and enabled_was_explicit:
|
||||
# Mark the explicit enable/disable so the registry-driven
|
||||
# plugin-enable pass in _apply_env_overrides honors an
|
||||
# explicit ``enabled: false`` for migrated plugin platforms
|
||||
# (slack, telegram, matrix, dingtalk, whatsapp, feishu …)
|
||||
# instead of re-enabling them on token/SDK presence. #41112.
|
||||
extra["_enabled_explicit"] = True
|
||||
extra.update(bridged)
|
||||
|
||||
|
|
@ -1067,28 +1067,10 @@ def load_gateway_config() -> GatewayConfig:
|
|||
_, extra = _ensure_platform_extra_dict(platforms_data, entry.name)
|
||||
extra.update(seeded)
|
||||
|
||||
# Slack settings → env vars (env vars take precedence)
|
||||
slack_cfg = yaml_cfg.get("slack", {})
|
||||
if isinstance(slack_cfg, dict):
|
||||
if "require_mention" in slack_cfg and not os.getenv("SLACK_REQUIRE_MENTION"):
|
||||
os.environ["SLACK_REQUIRE_MENTION"] = str(slack_cfg["require_mention"]).lower()
|
||||
if "strict_mention" in slack_cfg and not os.getenv("SLACK_STRICT_MENTION"):
|
||||
os.environ["SLACK_STRICT_MENTION"] = str(slack_cfg["strict_mention"]).lower()
|
||||
if "allow_bots" in slack_cfg and not os.getenv("SLACK_ALLOW_BOTS"):
|
||||
os.environ["SLACK_ALLOW_BOTS"] = str(slack_cfg["allow_bots"]).lower()
|
||||
frc = slack_cfg.get("free_response_channels")
|
||||
if frc is not None and not os.getenv("SLACK_FREE_RESPONSE_CHANNELS"):
|
||||
if isinstance(frc, list):
|
||||
frc = ",".join(str(v) for v in frc)
|
||||
os.environ["SLACK_FREE_RESPONSE_CHANNELS"] = str(frc)
|
||||
if "reactions" in slack_cfg and not os.getenv("SLACK_REACTIONS"):
|
||||
os.environ["SLACK_REACTIONS"] = str(slack_cfg["reactions"]).lower()
|
||||
# allowed_channels: if set, bot ONLY responds in these channels (whitelist)
|
||||
ac = slack_cfg.get("allowed_channels")
|
||||
if ac is not None and not os.getenv("SLACK_ALLOWED_CHANNELS"):
|
||||
if isinstance(ac, list):
|
||||
ac = ",".join(str(v) for v in ac)
|
||||
os.environ["SLACK_ALLOWED_CHANNELS"] = str(ac)
|
||||
# Slack settings → env vars: migrated to the slack plugin's
|
||||
# ``apply_yaml_config_fn`` hook (see plugins/platforms/slack/
|
||||
# adapter.py::_apply_yaml_config), dispatched in the
|
||||
# ``apply_yaml_config_fn`` loop above. #41112 / #3823.
|
||||
|
||||
# Bridge top-level require_mention to Telegram when the telegram: section
|
||||
# does not already provide one. Users often write "require_mention: true"
|
||||
|
|
@ -1101,125 +1083,22 @@ def load_gateway_config() -> GatewayConfig:
|
|||
_tg_plat = platforms_data.setdefault(Platform.TELEGRAM.value, {})
|
||||
_tg_extra = _tg_plat.setdefault("extra", {})
|
||||
_tg_extra.setdefault("require_mention", _tl_require_mention)
|
||||
# Also bridge to the TELEGRAM_REQUIRE_MENTION env var that the
|
||||
# adapter reads at runtime. This used to live in the telegram_cfg
|
||||
# block in core; it stays in core because it keys off the TOP-LEVEL
|
||||
# require_mention (not a telegram: block), so the telegram plugin's
|
||||
# apply_yaml_config_fn hook — which only runs when a telegram config
|
||||
# block exists — can't cover the no-telegram-block case (#3979).
|
||||
if not os.getenv("TELEGRAM_REQUIRE_MENTION"):
|
||||
os.environ["TELEGRAM_REQUIRE_MENTION"] = str(_tl_require_mention).lower()
|
||||
|
||||
# Telegram settings → env vars (env vars take precedence)
|
||||
telegram_cfg = yaml_cfg.get("telegram", {})
|
||||
if isinstance(telegram_cfg, dict):
|
||||
# Bridge top-level legacy `telegram.disable_topic_auto_rename` into
|
||||
# gateway.platforms.telegram.extra so the runtime config sees it.
|
||||
# Read as a runtime-config flag, not env-var (no need for env override).
|
||||
if "disable_topic_auto_rename" in telegram_cfg:
|
||||
_tg_plat = platforms_data.setdefault(Platform.TELEGRAM.value, {})
|
||||
_tg_extra = _tg_plat.setdefault("extra", {})
|
||||
_tg_extra.setdefault(
|
||||
"disable_topic_auto_rename",
|
||||
telegram_cfg["disable_topic_auto_rename"],
|
||||
)
|
||||
# Prefer telegram.require_mention; fall back to the top-level shorthand.
|
||||
_effective_rm = telegram_cfg.get("require_mention", yaml_cfg.get("require_mention"))
|
||||
if _effective_rm is not None and not os.getenv("TELEGRAM_REQUIRE_MENTION"):
|
||||
os.environ["TELEGRAM_REQUIRE_MENTION"] = str(_effective_rm).lower()
|
||||
if "mention_patterns" in telegram_cfg and not os.getenv("TELEGRAM_MENTION_PATTERNS"):
|
||||
os.environ["TELEGRAM_MENTION_PATTERNS"] = json.dumps(telegram_cfg["mention_patterns"])
|
||||
if "exclusive_bot_mentions" in telegram_cfg and not os.getenv("TELEGRAM_EXCLUSIVE_BOT_MENTIONS"):
|
||||
os.environ["TELEGRAM_EXCLUSIVE_BOT_MENTIONS"] = str(telegram_cfg["exclusive_bot_mentions"]).lower()
|
||||
if "guest_mode" in telegram_cfg and not os.getenv("TELEGRAM_GUEST_MODE"):
|
||||
os.environ["TELEGRAM_GUEST_MODE"] = str(telegram_cfg["guest_mode"]).lower()
|
||||
if "observe_unmentioned_group_messages" in telegram_cfg and not os.getenv("TELEGRAM_OBSERVE_UNMENTIONED_GROUP_MESSAGES"):
|
||||
os.environ["TELEGRAM_OBSERVE_UNMENTIONED_GROUP_MESSAGES"] = str(telegram_cfg["observe_unmentioned_group_messages"]).lower()
|
||||
frc = telegram_cfg.get("free_response_chats")
|
||||
if frc is not None and not os.getenv("TELEGRAM_FREE_RESPONSE_CHATS"):
|
||||
if isinstance(frc, list):
|
||||
frc = ",".join(str(v) for v in frc)
|
||||
os.environ["TELEGRAM_FREE_RESPONSE_CHATS"] = str(frc)
|
||||
# allowed_chats: if set, bot ONLY responds in these group chats (whitelist)
|
||||
ac = telegram_cfg.get("allowed_chats")
|
||||
if ac is not None and not os.getenv("TELEGRAM_ALLOWED_CHATS"):
|
||||
if isinstance(ac, list):
|
||||
ac = ",".join(str(v) for v in ac)
|
||||
os.environ["TELEGRAM_ALLOWED_CHATS"] = str(ac)
|
||||
allowed_topics = telegram_cfg.get("allowed_topics")
|
||||
if allowed_topics is not None and not os.getenv("TELEGRAM_ALLOWED_TOPICS"):
|
||||
if isinstance(allowed_topics, list):
|
||||
allowed_topics = ",".join(str(v) for v in allowed_topics)
|
||||
os.environ["TELEGRAM_ALLOWED_TOPICS"] = str(allowed_topics)
|
||||
ignored_threads = telegram_cfg.get("ignored_threads")
|
||||
if ignored_threads is not None and not os.getenv("TELEGRAM_IGNORED_THREADS"):
|
||||
if isinstance(ignored_threads, list):
|
||||
ignored_threads = ",".join(str(v) for v in ignored_threads)
|
||||
os.environ["TELEGRAM_IGNORED_THREADS"] = str(ignored_threads)
|
||||
if "reactions" in telegram_cfg and not os.getenv("TELEGRAM_REACTIONS"):
|
||||
os.environ["TELEGRAM_REACTIONS"] = str(telegram_cfg["reactions"]).lower()
|
||||
if "proxy_url" in telegram_cfg and not os.getenv("TELEGRAM_PROXY"):
|
||||
os.environ["TELEGRAM_PROXY"] = str(telegram_cfg["proxy_url"]).strip()
|
||||
# reply_to_mode: top-level preferred, falls back to extra.reply_to_mode
|
||||
# YAML 1.1 parses bare 'off' as boolean False — coerce to string "off".
|
||||
_telegram_extra = telegram_cfg.get("extra") if isinstance(telegram_cfg.get("extra"), dict) else {}
|
||||
_telegram_rtm = (
|
||||
telegram_cfg["reply_to_mode"] if "reply_to_mode" in telegram_cfg
|
||||
else _telegram_extra.get("reply_to_mode")
|
||||
)
|
||||
if _telegram_rtm is not None and not os.getenv("TELEGRAM_REPLY_TO_MODE"):
|
||||
_rtm_str = "off" if _telegram_rtm is False else str(_telegram_rtm).lower()
|
||||
os.environ["TELEGRAM_REPLY_TO_MODE"] = _rtm_str
|
||||
allowed_users = telegram_cfg.get("allow_from")
|
||||
if allowed_users is not None and not os.getenv("TELEGRAM_ALLOWED_USERS"):
|
||||
if isinstance(allowed_users, list):
|
||||
allowed_users = ",".join(str(v) for v in allowed_users)
|
||||
os.environ["TELEGRAM_ALLOWED_USERS"] = str(allowed_users)
|
||||
group_allowed_users = telegram_cfg.get("group_allow_from")
|
||||
if group_allowed_users is not None and not os.getenv("TELEGRAM_GROUP_ALLOWED_USERS"):
|
||||
if isinstance(group_allowed_users, list):
|
||||
group_allowed_users = ",".join(str(v) for v in group_allowed_users)
|
||||
os.environ["TELEGRAM_GROUP_ALLOWED_USERS"] = str(group_allowed_users)
|
||||
group_allowed_chats = telegram_cfg.get("group_allowed_chats")
|
||||
if group_allowed_chats is not None and not os.getenv("TELEGRAM_GROUP_ALLOWED_CHATS"):
|
||||
if isinstance(group_allowed_chats, list):
|
||||
group_allowed_chats = ",".join(str(v) for v in group_allowed_chats)
|
||||
os.environ["TELEGRAM_GROUP_ALLOWED_CHATS"] = str(group_allowed_chats)
|
||||
for _telegram_extra_key in ("guest_mode", "disable_link_previews", "observe_unmentioned_group_messages"):
|
||||
if _telegram_extra_key in telegram_cfg:
|
||||
plat_data = platforms_data.setdefault(Platform.TELEGRAM.value, {})
|
||||
if not isinstance(plat_data, dict):
|
||||
plat_data = {}
|
||||
platforms_data[Platform.TELEGRAM.value] = plat_data
|
||||
extra = plat_data.setdefault("extra", {})
|
||||
if not isinstance(extra, dict):
|
||||
extra = {}
|
||||
plat_data["extra"] = extra
|
||||
extra[_telegram_extra_key] = telegram_cfg[_telegram_extra_key]
|
||||
if _telegram_extra:
|
||||
_plat_data, _plat_extra = _ensure_platform_extra_dict(
|
||||
platforms_data, Platform.TELEGRAM.value
|
||||
)
|
||||
for _telegram_extra_key, _telegram_extra_value in _telegram_extra.items():
|
||||
_plat_extra.setdefault(_telegram_extra_key, _telegram_extra_value)
|
||||
# Telegram settings → env vars / extra: migrated to the telegram
|
||||
# plugin's apply_yaml_config_fn hook
|
||||
# (plugins/platforms/telegram/adapter.py). #41112 / #3823.
|
||||
|
||||
whatsapp_cfg = yaml_cfg.get("whatsapp", {})
|
||||
if isinstance(whatsapp_cfg, dict):
|
||||
if "require_mention" in whatsapp_cfg and not os.getenv("WHATSAPP_REQUIRE_MENTION"):
|
||||
os.environ["WHATSAPP_REQUIRE_MENTION"] = str(whatsapp_cfg["require_mention"]).lower()
|
||||
if "mention_patterns" in whatsapp_cfg and not os.getenv("WHATSAPP_MENTION_PATTERNS"):
|
||||
os.environ["WHATSAPP_MENTION_PATTERNS"] = json.dumps(whatsapp_cfg["mention_patterns"])
|
||||
frc = whatsapp_cfg.get("free_response_chats")
|
||||
if frc is not None and not os.getenv("WHATSAPP_FREE_RESPONSE_CHATS"):
|
||||
if isinstance(frc, list):
|
||||
frc = ",".join(str(v) for v in frc)
|
||||
os.environ["WHATSAPP_FREE_RESPONSE_CHATS"] = str(frc)
|
||||
if "dm_policy" in whatsapp_cfg and not os.getenv("WHATSAPP_DM_POLICY"):
|
||||
os.environ["WHATSAPP_DM_POLICY"] = str(whatsapp_cfg["dm_policy"]).lower()
|
||||
af = whatsapp_cfg.get("allow_from")
|
||||
if af is not None and not os.getenv("WHATSAPP_ALLOWED_USERS"):
|
||||
if isinstance(af, list):
|
||||
af = ",".join(str(v) for v in af)
|
||||
os.environ["WHATSAPP_ALLOWED_USERS"] = str(af)
|
||||
if "group_policy" in whatsapp_cfg and not os.getenv("WHATSAPP_GROUP_POLICY"):
|
||||
os.environ["WHATSAPP_GROUP_POLICY"] = str(whatsapp_cfg["group_policy"]).lower()
|
||||
gaf = whatsapp_cfg.get("group_allow_from")
|
||||
if gaf is not None and not os.getenv("WHATSAPP_GROUP_ALLOWED_USERS"):
|
||||
if isinstance(gaf, list):
|
||||
gaf = ",".join(str(v) for v in gaf)
|
||||
os.environ["WHATSAPP_GROUP_ALLOWED_USERS"] = str(gaf)
|
||||
# WhatsApp settings → env vars: migrated to the whatsapp plugin's
|
||||
# apply_yaml_config_fn hook (plugins/platforms/whatsapp/adapter.py).
|
||||
# #41112 / #3823.
|
||||
|
||||
# Signal settings → env vars (env vars take precedence)
|
||||
signal_cfg = yaml_cfg.get("signal", {})
|
||||
|
|
@ -1227,72 +1106,20 @@ def load_gateway_config() -> GatewayConfig:
|
|||
if "require_mention" in signal_cfg and not os.getenv("SIGNAL_REQUIRE_MENTION"):
|
||||
os.environ["SIGNAL_REQUIRE_MENTION"] = str(signal_cfg["require_mention"]).lower()
|
||||
|
||||
# DingTalk settings → env vars (env vars take precedence)
|
||||
dingtalk_cfg = yaml_cfg.get("dingtalk", {})
|
||||
if isinstance(dingtalk_cfg, dict):
|
||||
if "require_mention" in dingtalk_cfg and not os.getenv("DINGTALK_REQUIRE_MENTION"):
|
||||
os.environ["DINGTALK_REQUIRE_MENTION"] = str(dingtalk_cfg["require_mention"]).lower()
|
||||
if "mention_patterns" in dingtalk_cfg and not os.getenv("DINGTALK_MENTION_PATTERNS"):
|
||||
os.environ["DINGTALK_MENTION_PATTERNS"] = json.dumps(dingtalk_cfg["mention_patterns"])
|
||||
frc = dingtalk_cfg.get("free_response_chats")
|
||||
if frc is not None and not os.getenv("DINGTALK_FREE_RESPONSE_CHATS"):
|
||||
if isinstance(frc, list):
|
||||
frc = ",".join(str(v) for v in frc)
|
||||
os.environ["DINGTALK_FREE_RESPONSE_CHATS"] = str(frc)
|
||||
# allowed_chats: if set, bot ONLY responds in these group chats (whitelist)
|
||||
ac = dingtalk_cfg.get("allowed_chats")
|
||||
if ac is not None and not os.getenv("DINGTALK_ALLOWED_CHATS"):
|
||||
if isinstance(ac, list):
|
||||
ac = ",".join(str(v) for v in ac)
|
||||
os.environ["DINGTALK_ALLOWED_CHATS"] = str(ac)
|
||||
allowed = dingtalk_cfg.get("allowed_users")
|
||||
if allowed is not None and not os.getenv("DINGTALK_ALLOWED_USERS"):
|
||||
if isinstance(allowed, list):
|
||||
allowed = ",".join(str(v) for v in allowed)
|
||||
os.environ["DINGTALK_ALLOWED_USERS"] = str(allowed)
|
||||
# DingTalk settings → env vars: migrated to the dingtalk plugin's
|
||||
# apply_yaml_config_fn hook (plugins/platforms/dingtalk/adapter.py).
|
||||
# #41112 / #3823.
|
||||
|
||||
# Mattermost config bridge moved into plugins/platforms/mattermost/
|
||||
# adapter.py::_apply_yaml_config — see #25443 (apply_yaml_config_fn).
|
||||
|
||||
# Matrix settings → env vars (env vars take precedence)
|
||||
matrix_cfg = yaml_cfg.get("matrix", {})
|
||||
if isinstance(matrix_cfg, dict):
|
||||
if "require_mention" in matrix_cfg and not os.getenv("MATRIX_REQUIRE_MENTION"):
|
||||
os.environ["MATRIX_REQUIRE_MENTION"] = str(matrix_cfg["require_mention"]).lower()
|
||||
allowed_users = matrix_cfg.get("allowed_users")
|
||||
if allowed_users is not None and not os.getenv("MATRIX_ALLOWED_USERS"):
|
||||
if isinstance(allowed_users, list):
|
||||
allowed_users = ",".join(str(v) for v in allowed_users)
|
||||
os.environ["MATRIX_ALLOWED_USERS"] = str(allowed_users)
|
||||
allowed_rooms = matrix_cfg.get("allowed_rooms")
|
||||
if allowed_rooms is not None and not os.getenv("MATRIX_ALLOWED_ROOMS"):
|
||||
if isinstance(allowed_rooms, list):
|
||||
allowed_rooms = ",".join(str(v) for v in allowed_rooms)
|
||||
os.environ["MATRIX_ALLOWED_ROOMS"] = str(allowed_rooms)
|
||||
frc = matrix_cfg.get("free_response_rooms")
|
||||
if frc is not None and not os.getenv("MATRIX_FREE_RESPONSE_ROOMS"):
|
||||
if isinstance(frc, list):
|
||||
frc = ",".join(str(v) for v in frc)
|
||||
os.environ["MATRIX_FREE_RESPONSE_ROOMS"] = str(frc)
|
||||
ignore_patterns = matrix_cfg.get("ignore_user_patterns")
|
||||
if ignore_patterns is not None and not os.getenv("MATRIX_IGNORE_USER_PATTERNS"):
|
||||
if isinstance(ignore_patterns, list):
|
||||
ignore_patterns = ",".join(str(v) for v in ignore_patterns)
|
||||
os.environ["MATRIX_IGNORE_USER_PATTERNS"] = str(ignore_patterns)
|
||||
if "process_notices" in matrix_cfg and not os.getenv("MATRIX_PROCESS_NOTICES"):
|
||||
os.environ["MATRIX_PROCESS_NOTICES"] = str(matrix_cfg["process_notices"]).lower()
|
||||
if "session_scope" in matrix_cfg and not os.getenv("MATRIX_SESSION_SCOPE"):
|
||||
os.environ["MATRIX_SESSION_SCOPE"] = str(matrix_cfg["session_scope"]).lower()
|
||||
if "auto_thread" in matrix_cfg and not os.getenv("MATRIX_AUTO_THREAD"):
|
||||
os.environ["MATRIX_AUTO_THREAD"] = str(matrix_cfg["auto_thread"]).lower()
|
||||
if "dm_mention_threads" in matrix_cfg and not os.getenv("MATRIX_DM_MENTION_THREADS"):
|
||||
os.environ["MATRIX_DM_MENTION_THREADS"] = str(matrix_cfg["dm_mention_threads"]).lower()
|
||||
# Matrix settings → env vars: migrated to the matrix plugin's
|
||||
# apply_yaml_config_fn hook (plugins/platforms/matrix/adapter.py).
|
||||
# #41112 / #3823.
|
||||
|
||||
# Feishu settings → env vars (env vars take precedence)
|
||||
feishu_cfg = yaml_cfg.get("feishu", {})
|
||||
if isinstance(feishu_cfg, dict):
|
||||
if "allow_bots" in feishu_cfg and not os.getenv("FEISHU_ALLOW_BOTS"):
|
||||
os.environ["FEISHU_ALLOW_BOTS"] = str(feishu_cfg["allow_bots"]).lower()
|
||||
# Feishu settings → env vars: migrated to the feishu plugin's
|
||||
# apply_yaml_config_fn hook (plugins/platforms/feishu/adapter.py).
|
||||
# #41112 / #3823.
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
|
|
@ -1391,7 +1218,13 @@ def _apply_env_overrides(config: GatewayConfig) -> None:
|
|||
return config.platforms[platform]
|
||||
|
||||
platform_config = config.platforms[platform]
|
||||
enabled_was_explicit = bool(platform_config.extra.pop("_enabled_explicit", False))
|
||||
# Read (don't pop) the explicit-enable marker: the registry-driven
|
||||
# plugin-enable pass later in this function also needs it to avoid
|
||||
# re-enabling a platform the user explicitly disabled (migrated plugin
|
||||
# platforms — telegram, matrix — flow through here too, #41112). The
|
||||
# flag is cleared once for all platforms in the final cleanup at the
|
||||
# end of _apply_env_overrides.
|
||||
enabled_was_explicit = bool(platform_config.extra.get("_enabled_explicit", False))
|
||||
if not platform_config.enabled and not enabled_was_explicit:
|
||||
platform_config.enabled = True
|
||||
return platform_config
|
||||
|
|
@ -1534,7 +1367,12 @@ def _apply_env_overrides(config: GatewayConfig) -> None:
|
|||
config.platforms[Platform.SLACK].enabled = True
|
||||
else:
|
||||
slack_config = config.platforms[Platform.SLACK]
|
||||
enabled_was_explicit = bool(slack_config.extra.pop("_enabled_explicit", False))
|
||||
# Read (don't pop) the explicit-enable marker: the registry-driven
|
||||
# plugin-enable pass below also needs it to avoid re-enabling a
|
||||
# platform the user explicitly disabled (Slack is now a plugin
|
||||
# entry — #41112). The flag is cleared once for all platforms in
|
||||
# the final cleanup at the end of _apply_env_overrides.
|
||||
enabled_was_explicit = bool(slack_config.extra.get("_enabled_explicit", False))
|
||||
if not slack_config.enabled and not enabled_was_explicit:
|
||||
# Top-level Slack settings such as channel prompts should not
|
||||
# turn an env-token setup into a disabled platform. Only an
|
||||
|
|
@ -2076,6 +1914,19 @@ def _apply_env_overrides(config: GatewayConfig) -> None:
|
|||
continue
|
||||
platform = Platform(entry.name)
|
||||
existing_cfg = config.platforms.get(platform)
|
||||
# Respect an explicit ``enabled: false`` (YAML / gateway.json /
|
||||
# dashboard PUT). ``_enabled_explicit`` is set in
|
||||
# load_gateway_config() (via _merge_platform_map / the shared-key
|
||||
# loop) when the user wrote ``enabled`` for this platform; if they
|
||||
# explicitly disabled it, never re-enable here just because
|
||||
# check_fn() / is_connected() pass (e.g. a token is present but the
|
||||
# user set telegram.enabled: false). #41112.
|
||||
if (
|
||||
existing_cfg is not None
|
||||
and not existing_cfg.enabled
|
||||
and bool((existing_cfg.extra or {}).get("_enabled_explicit", False))
|
||||
):
|
||||
continue
|
||||
# Seed candidate extras from ``env_enablement_fn`` so plugins
|
||||
# whose ``is_connected`` reads ``config.extra`` (e.g. Google
|
||||
# Chat's ``_is_connected`` checks ``config.extra["project_id"]``)
|
||||
|
|
|
|||
|
|
@ -6988,43 +6988,7 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
|
|||
logger.debug("Platform registry lookup for '%s' failed: %s", platform.value, e)
|
||||
# Fall through to built-in adapters below
|
||||
|
||||
if platform == Platform.TELEGRAM:
|
||||
from gateway.platforms.telegram import TelegramAdapter, check_telegram_requirements
|
||||
if not check_telegram_requirements():
|
||||
logger.warning("Telegram: python-telegram-bot not installed")
|
||||
return None
|
||||
adapter = TelegramAdapter(config)
|
||||
# Apply Telegram notification mode from config. Controls whether
|
||||
# intermediate messages (tool progress, streaming, status) trigger
|
||||
# push notifications. Supports ENV override for quick testing.
|
||||
_notify_mode = os.getenv("HERMES_TELEGRAM_NOTIFICATIONS", "")
|
||||
if not _notify_mode:
|
||||
try:
|
||||
_gw_cfg = _load_gateway_config()
|
||||
_raw = cfg_get(_gw_cfg, "display", "platforms", "telegram", "notifications")
|
||||
if _raw not in {None, ""}:
|
||||
_notify_mode = str(_raw).strip().lower()
|
||||
except Exception:
|
||||
pass
|
||||
_notify_mode = _notify_mode or "important"
|
||||
if _notify_mode not in {"all", "important"}:
|
||||
logger.warning(
|
||||
"Unknown telegram notifications mode '%s', "
|
||||
"defaulting to 'important' (valid: all, important)",
|
||||
_notify_mode,
|
||||
)
|
||||
_notify_mode = "important"
|
||||
adapter._notifications_mode = _notify_mode
|
||||
return adapter
|
||||
|
||||
elif platform == Platform.WHATSAPP:
|
||||
from gateway.platforms.whatsapp import WhatsAppAdapter, check_whatsapp_requirements
|
||||
if not check_whatsapp_requirements():
|
||||
logger.warning("WhatsApp: Node.js not installed or bridge not configured")
|
||||
return None
|
||||
return WhatsAppAdapter(config)
|
||||
|
||||
elif platform == Platform.WHATSAPP_CLOUD:
|
||||
if platform == Platform.WHATSAPP_CLOUD:
|
||||
from gateway.platforms.whatsapp_cloud import (
|
||||
WhatsAppCloudAdapter,
|
||||
check_whatsapp_cloud_requirements,
|
||||
|
|
@ -7036,13 +7000,6 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
|
|||
return None
|
||||
return WhatsAppCloudAdapter(config)
|
||||
|
||||
elif platform == Platform.SLACK:
|
||||
from gateway.platforms.slack import SlackAdapter, check_slack_requirements
|
||||
if not check_slack_requirements():
|
||||
logger.warning("Slack: slack-bolt not installed. Run: pip install 'hermes-agent[slack]'")
|
||||
return None
|
||||
return SlackAdapter(config)
|
||||
|
||||
elif platform == Platform.SIGNAL:
|
||||
from gateway.platforms.signal import SignalAdapter, check_signal_requirements
|
||||
if not check_signal_requirements():
|
||||
|
|
@ -7050,51 +7007,6 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
|
|||
return None
|
||||
return SignalAdapter(config)
|
||||
|
||||
elif platform == Platform.EMAIL:
|
||||
from gateway.platforms.email import EmailAdapter, check_email_requirements
|
||||
if not check_email_requirements():
|
||||
logger.warning("Email: EMAIL_ADDRESS, EMAIL_PASSWORD, EMAIL_IMAP_HOST, or EMAIL_SMTP_HOST not set")
|
||||
return None
|
||||
return EmailAdapter(config)
|
||||
|
||||
elif platform == Platform.SMS:
|
||||
from gateway.platforms.sms import SmsAdapter, check_sms_requirements
|
||||
if not check_sms_requirements():
|
||||
logger.warning("SMS: aiohttp not installed or TWILIO_ACCOUNT_SID/TWILIO_AUTH_TOKEN not set")
|
||||
return None
|
||||
return SmsAdapter(config)
|
||||
|
||||
elif platform == Platform.DINGTALK:
|
||||
from gateway.platforms.dingtalk import DingTalkAdapter, check_dingtalk_requirements
|
||||
if not check_dingtalk_requirements():
|
||||
logger.warning("DingTalk: dingtalk-stream not installed or DINGTALK_CLIENT_ID/SECRET not set")
|
||||
return None
|
||||
return DingTalkAdapter(config)
|
||||
|
||||
elif platform == Platform.FEISHU:
|
||||
from gateway.platforms.feishu import FeishuAdapter, check_feishu_requirements
|
||||
if not check_feishu_requirements():
|
||||
logger.warning("Feishu: lark-oapi not installed or FEISHU_APP_ID/SECRET not set")
|
||||
return None
|
||||
return FeishuAdapter(config)
|
||||
|
||||
elif platform == Platform.WECOM_CALLBACK:
|
||||
from gateway.platforms.wecom_callback import (
|
||||
WecomCallbackAdapter,
|
||||
check_wecom_callback_requirements,
|
||||
)
|
||||
if not check_wecom_callback_requirements():
|
||||
logger.warning("WeComCallback: aiohttp/httpx/defusedxml not installed")
|
||||
return None
|
||||
return WecomCallbackAdapter(config)
|
||||
|
||||
elif platform == Platform.WECOM:
|
||||
from gateway.platforms.wecom import WeComAdapter, check_wecom_requirements
|
||||
if not check_wecom_requirements():
|
||||
logger.warning("WeCom: aiohttp not installed or WECOM_BOT_ID/SECRET not set")
|
||||
return None
|
||||
return WeComAdapter(config)
|
||||
|
||||
elif platform == Platform.WEIXIN:
|
||||
from gateway.platforms.weixin import WeixinAdapter, check_weixin_requirements
|
||||
if not check_weixin_requirements():
|
||||
|
|
@ -7102,13 +7014,6 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
|
|||
return None
|
||||
return WeixinAdapter(config)
|
||||
|
||||
elif platform == Platform.MATRIX:
|
||||
from gateway.platforms.matrix import MatrixAdapter, check_matrix_requirements
|
||||
if not check_matrix_requirements():
|
||||
logger.warning("Matrix: mautrix not installed or credentials not set. Run: pip install 'mautrix[encryption]'")
|
||||
return None
|
||||
return MatrixAdapter(config)
|
||||
|
||||
elif platform == Platform.API_SERVER:
|
||||
from gateway.platforms.api_server import APIServerAdapter, check_api_server_requirements
|
||||
if not check_api_server_requirements():
|
||||
|
|
|
|||
|
|
@ -4210,134 +4210,18 @@ def run_gateway(verbose: int = 0, quiet: bool = False, replace: bool = False, fo
|
|||
# Per-platform config: each entry defines the env vars, setup instructions,
|
||||
# and prompts needed to configure a messaging platform.
|
||||
_PLATFORMS = [
|
||||
{
|
||||
"key": "telegram",
|
||||
"label": "Telegram",
|
||||
"emoji": "📱",
|
||||
"token_var": "TELEGRAM_BOT_TOKEN",
|
||||
"setup_instructions": [
|
||||
"1. Open Telegram and message @BotFather",
|
||||
"2. Send /newbot and follow the prompts to create your bot",
|
||||
"3. Copy the bot token BotFather gives you",
|
||||
"4. To find your user ID: message @userinfobot — it replies with your numeric ID",
|
||||
],
|
||||
"vars": [
|
||||
{
|
||||
"name": "TELEGRAM_BOT_TOKEN",
|
||||
"prompt": "Bot token",
|
||||
"password": True,
|
||||
"help": "Paste the token from @BotFather (step 3 above).",
|
||||
},
|
||||
{
|
||||
"name": "TELEGRAM_ALLOWED_USERS",
|
||||
"prompt": "Allowed user IDs (comma-separated)",
|
||||
"password": False,
|
||||
"is_allowlist": True,
|
||||
"help": "Paste your user ID from step 4 above.",
|
||||
},
|
||||
{
|
||||
"name": "TELEGRAM_HOME_CHANNEL",
|
||||
"prompt": "Home channel ID (for cron/notification delivery, or empty to set later with /set-home)",
|
||||
"password": False,
|
||||
"help": "For DMs, this is your user ID. You can set it later by typing /set-home in chat.",
|
||||
},
|
||||
],
|
||||
},
|
||||
# Telegram moved to plugins/platforms/telegram/ — setup metadata discovered
|
||||
# dynamically via the platform registry entry registered by
|
||||
# plugins/platforms/telegram/adapter.py::register(). #41112.
|
||||
# Discord moved to plugins/platforms/discord/ — its setup metadata is
|
||||
# discovered dynamically via _all_platforms() from the platform registry
|
||||
# entry registered by plugins/platforms/discord/adapter.py::register().
|
||||
{
|
||||
"key": "slack",
|
||||
"label": "Slack",
|
||||
"emoji": "💼",
|
||||
"token_var": "SLACK_BOT_TOKEN",
|
||||
"setup_instructions": [
|
||||
"1. Go to https://api.slack.com/apps → Create New App → From Scratch",
|
||||
"2. Enable Socket Mode: Settings → Socket Mode → Enable",
|
||||
" Create an App-Level Token with scope: connections:write → copy xapp-... token",
|
||||
"3. Add Bot Token Scopes: Features → OAuth & Permissions → Scopes",
|
||||
" Required: chat:write, app_mentions:read, channels:history, channels:read,",
|
||||
" groups:history, im:history, im:read, im:write, users:read, files:read, files:write",
|
||||
"4. Subscribe to Events: Features → Event Subscriptions → Enable",
|
||||
" Required events: message.im, message.channels, app_mention",
|
||||
" Optional: message.groups (for private channels)",
|
||||
" ⚠ Without message.channels the bot will ONLY work in DMs!",
|
||||
"5. Install to Workspace: Settings → Install App → copy xoxb-... token",
|
||||
"6. Reinstall the app after any scope or event changes",
|
||||
"7. Find your user ID: click your profile → three dots → Copy member ID",
|
||||
"8. Invite the bot to channels: /invite @YourBot",
|
||||
],
|
||||
"vars": [
|
||||
{
|
||||
"name": "SLACK_BOT_TOKEN",
|
||||
"prompt": "Bot Token (xoxb-...)",
|
||||
"password": True,
|
||||
"help": "Paste the bot token from step 3 above.",
|
||||
},
|
||||
{
|
||||
"name": "SLACK_APP_TOKEN",
|
||||
"prompt": "App Token (xapp-...)",
|
||||
"password": True,
|
||||
"help": "Paste the app-level token from step 4 above.",
|
||||
},
|
||||
{
|
||||
"name": "SLACK_ALLOWED_USERS",
|
||||
"prompt": "Allowed user IDs (comma-separated)",
|
||||
"password": False,
|
||||
"is_allowlist": True,
|
||||
"help": "Paste your member ID from step 7 above.",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
"key": "matrix",
|
||||
"label": "Matrix",
|
||||
"emoji": "🔐",
|
||||
"token_var": "MATRIX_ACCESS_TOKEN",
|
||||
"setup_instructions": [
|
||||
"1. Works with any Matrix homeserver (self-hosted Synapse/Conduit/Dendrite or matrix.org)",
|
||||
"2. Create a bot user on your homeserver, or use your own account",
|
||||
"3. Get an access token: Element → Settings → Help & About → Access Token",
|
||||
" Or via API: curl -X POST https://your-server/_matrix/client/v3/login \\",
|
||||
' -d \'{"type":"m.login.password","user":"@bot:server","password":"..."}\'',
|
||||
"4. Alternatively, provide user ID + password and Hermes will log in directly",
|
||||
"5. For E2EE: set MATRIX_ENCRYPTION=true (requires pip install 'mautrix[encryption]')",
|
||||
"6. To find your user ID: it's @username:your-server (shown in Element profile)",
|
||||
],
|
||||
"vars": [
|
||||
{
|
||||
"name": "MATRIX_HOMESERVER",
|
||||
"prompt": "Homeserver URL (e.g. https://matrix.example.org)",
|
||||
"password": False,
|
||||
"help": "Your Matrix homeserver URL. Works with any self-hosted instance.",
|
||||
},
|
||||
{
|
||||
"name": "MATRIX_ACCESS_TOKEN",
|
||||
"prompt": "Access token (leave empty to use password login instead)",
|
||||
"password": True,
|
||||
"help": "Paste your access token, or leave empty and provide user ID + password below.",
|
||||
},
|
||||
{
|
||||
"name": "MATRIX_USER_ID",
|
||||
"prompt": "User ID (@bot:server — required for password login)",
|
||||
"password": False,
|
||||
"help": "Full Matrix user ID, e.g. @hermes:matrix.example.org",
|
||||
},
|
||||
{
|
||||
"name": "MATRIX_ALLOWED_USERS",
|
||||
"prompt": "Allowed user IDs (comma-separated, e.g. @you:server)",
|
||||
"password": False,
|
||||
"is_allowlist": True,
|
||||
"help": "Matrix user IDs who can interact with the bot.",
|
||||
},
|
||||
{
|
||||
"name": "MATRIX_HOME_ROOM",
|
||||
"prompt": "Home room ID (for cron/notification delivery, or empty to set later with /set-home)",
|
||||
"password": False,
|
||||
"help": "Room ID (e.g. !abc123:server) for delivering cron results and notifications.",
|
||||
},
|
||||
],
|
||||
},
|
||||
# Slack moved to plugins/platforms/slack/ for the same reason — its setup
|
||||
# metadata is discovered dynamically via the platform registry entry
|
||||
# registered by plugins/platforms/slack/adapter.py::register(). #41112.
|
||||
# Matrix moved to plugins/platforms/matrix/ — setup metadata discovered
|
||||
# dynamically via the platform registry entry registered by
|
||||
# plugins/platforms/matrix/adapter.py::register(). #41112.
|
||||
{
|
||||
"key": "mattermost",
|
||||
"label": "Mattermost",
|
||||
|
|
@ -4387,289 +4271,18 @@ _PLATFORMS = [
|
|||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
"key": "whatsapp",
|
||||
"label": "WhatsApp",
|
||||
"emoji": "📲",
|
||||
"token_var": "WHATSAPP_ENABLED",
|
||||
},
|
||||
# WhatsApp moved to plugins/platforms/whatsapp/ — setup metadata discovered
|
||||
# dynamically via the platform registry entry registered by
|
||||
# plugins/platforms/whatsapp/adapter.py::register(). #41112.
|
||||
{
|
||||
"key": "signal",
|
||||
"label": "Signal",
|
||||
"emoji": "📡",
|
||||
"token_var": "SIGNAL_HTTP_URL",
|
||||
},
|
||||
{
|
||||
"key": "email",
|
||||
"label": "Email",
|
||||
"emoji": "📧",
|
||||
"token_var": "EMAIL_ADDRESS",
|
||||
"setup_instructions": [
|
||||
"1. Use a dedicated email account for your Hermes agent",
|
||||
"2. For Gmail: enable 2FA, then create an App Password at",
|
||||
" https://myaccount.google.com/apppasswords",
|
||||
"3. For other providers: use your email password or app-specific password",
|
||||
"4. IMAP must be enabled on your email account",
|
||||
],
|
||||
"vars": [
|
||||
{
|
||||
"name": "EMAIL_ADDRESS",
|
||||
"prompt": "Email address",
|
||||
"password": False,
|
||||
"help": "The email address Hermes will use (e.g., hermes@gmail.com).",
|
||||
},
|
||||
{
|
||||
"name": "EMAIL_PASSWORD",
|
||||
"prompt": "Email password (or app password)",
|
||||
"password": True,
|
||||
"help": "For Gmail, use an App Password (not your regular password).",
|
||||
},
|
||||
{
|
||||
"name": "EMAIL_IMAP_HOST",
|
||||
"prompt": "IMAP host",
|
||||
"password": False,
|
||||
"help": "e.g., imap.gmail.com for Gmail, outlook.office365.com for Outlook.",
|
||||
},
|
||||
{
|
||||
"name": "EMAIL_SMTP_HOST",
|
||||
"prompt": "SMTP host",
|
||||
"password": False,
|
||||
"help": "e.g., smtp.gmail.com for Gmail, smtp.office365.com for Outlook.",
|
||||
},
|
||||
{
|
||||
"name": "EMAIL_ALLOWED_USERS",
|
||||
"prompt": "Allowed sender emails (comma-separated)",
|
||||
"password": False,
|
||||
"is_allowlist": True,
|
||||
"help": "Only emails from these addresses will be processed.",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
"key": "sms",
|
||||
"label": "SMS (Twilio)",
|
||||
"emoji": "📱",
|
||||
"token_var": "TWILIO_ACCOUNT_SID",
|
||||
"setup_instructions": [
|
||||
"1. Create a Twilio account at https://www.twilio.com/",
|
||||
"2. Get your Account SID and Auth Token from the Twilio Console dashboard",
|
||||
"3. Buy or configure a phone number capable of sending SMS",
|
||||
"4. Set up your webhook URL for inbound SMS:",
|
||||
" Twilio Console → Phone Numbers → Active Numbers → your number",
|
||||
" → Messaging → A MESSAGE COMES IN → Webhook → https://your-server:8080/webhooks/twilio",
|
||||
],
|
||||
"vars": [
|
||||
{
|
||||
"name": "TWILIO_ACCOUNT_SID",
|
||||
"prompt": "Twilio Account SID",
|
||||
"password": False,
|
||||
"help": "Found on the Twilio Console dashboard.",
|
||||
},
|
||||
{
|
||||
"name": "TWILIO_AUTH_TOKEN",
|
||||
"prompt": "Twilio Auth Token",
|
||||
"password": True,
|
||||
"help": "Found on the Twilio Console dashboard (click to reveal).",
|
||||
},
|
||||
{
|
||||
"name": "TWILIO_PHONE_NUMBER",
|
||||
"prompt": "Twilio phone number (E.164 format, e.g. +15551234567)",
|
||||
"password": False,
|
||||
"help": "The Twilio phone number to send SMS from.",
|
||||
},
|
||||
{
|
||||
"name": "SMS_ALLOWED_USERS",
|
||||
"prompt": "Allowed phone numbers (comma-separated, E.164 format)",
|
||||
"password": False,
|
||||
"is_allowlist": True,
|
||||
"help": "Only messages from these phone numbers will be processed.",
|
||||
},
|
||||
{
|
||||
"name": "SMS_HOME_CHANNEL",
|
||||
"prompt": "Home channel phone number (for cron/notification delivery, or empty)",
|
||||
"password": False,
|
||||
"help": "Phone number to deliver cron job results and notifications to.",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
"key": "dingtalk",
|
||||
"label": "DingTalk",
|
||||
"emoji": "💬",
|
||||
"token_var": "DINGTALK_CLIENT_ID",
|
||||
"setup_instructions": [
|
||||
"1. Go to https://open-dev.dingtalk.com → Create Application",
|
||||
"2. Under 'Credentials', copy the AppKey (Client ID) and AppSecret (Client Secret)",
|
||||
"3. Enable 'Stream Mode' under the bot settings",
|
||||
"4. Add the bot to a group chat or message it directly",
|
||||
],
|
||||
"vars": [
|
||||
{
|
||||
"name": "DINGTALK_CLIENT_ID",
|
||||
"prompt": "AppKey (Client ID)",
|
||||
"password": False,
|
||||
"help": "The AppKey from your DingTalk application credentials.",
|
||||
},
|
||||
{
|
||||
"name": "DINGTALK_CLIENT_SECRET",
|
||||
"prompt": "AppSecret (Client Secret)",
|
||||
"password": True,
|
||||
"help": "The AppSecret from your DingTalk application credentials.",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
"key": "feishu",
|
||||
"label": "Feishu / Lark",
|
||||
"emoji": "🪽",
|
||||
"token_var": "FEISHU_APP_ID",
|
||||
"setup_instructions": [
|
||||
"1. Go to https://open.feishu.cn/ (or https://open.larksuite.com/ for Lark)",
|
||||
"2. Create an app and copy the App ID and App Secret",
|
||||
"3. Enable the Bot capability for the app",
|
||||
"4. Choose WebSocket (recommended) or Webhook connection mode",
|
||||
"5. Add the bot to a group chat or message it directly",
|
||||
"6. Restrict access with FEISHU_ALLOWED_USERS for production use",
|
||||
],
|
||||
"vars": [
|
||||
{
|
||||
"name": "FEISHU_APP_ID",
|
||||
"prompt": "App ID",
|
||||
"password": False,
|
||||
"help": "The App ID from your Feishu/Lark application.",
|
||||
},
|
||||
{
|
||||
"name": "FEISHU_APP_SECRET",
|
||||
"prompt": "App Secret",
|
||||
"password": True,
|
||||
"help": "The App Secret from your Feishu/Lark application.",
|
||||
},
|
||||
{
|
||||
"name": "FEISHU_DOMAIN",
|
||||
"prompt": "Domain — feishu or lark (default: feishu)",
|
||||
"password": False,
|
||||
"help": "Use 'feishu' for Feishu China, or 'lark' for Lark international.",
|
||||
},
|
||||
{
|
||||
"name": "FEISHU_CONNECTION_MODE",
|
||||
"prompt": "Connection mode — websocket or webhook (default: websocket)",
|
||||
"password": False,
|
||||
"help": "websocket is recommended unless you specifically need webhook mode.",
|
||||
},
|
||||
{
|
||||
"name": "FEISHU_ALLOWED_USERS",
|
||||
"prompt": "Allowed user IDs (comma-separated, or empty)",
|
||||
"password": False,
|
||||
"is_allowlist": True,
|
||||
"help": "Restrict which Feishu/Lark users can interact with the bot.",
|
||||
},
|
||||
{
|
||||
"name": "FEISHU_HOME_CHANNEL",
|
||||
"prompt": "Home chat ID (optional, for cron/notifications)",
|
||||
"password": False,
|
||||
"help": "Chat ID for scheduled results and notifications.",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
"key": "wecom",
|
||||
"label": "WeCom (Enterprise WeChat)",
|
||||
"emoji": "💬",
|
||||
"token_var": "WECOM_BOT_ID",
|
||||
"setup_instructions": [
|
||||
"1. Go to WeCom Admin Console → Applications → Create AI Bot",
|
||||
"2. Copy the Bot ID and Secret from the bot's credentials page",
|
||||
"3. The bot connects via WebSocket — no public endpoint needed",
|
||||
"4. Add the bot to a group chat or message it directly in WeCom",
|
||||
"5. Restrict access with WECOM_ALLOWED_USERS for production use",
|
||||
],
|
||||
"vars": [
|
||||
{
|
||||
"name": "WECOM_BOT_ID",
|
||||
"prompt": "Bot ID",
|
||||
"password": False,
|
||||
"help": "The Bot ID from your WeCom AI Bot.",
|
||||
},
|
||||
{
|
||||
"name": "WECOM_SECRET",
|
||||
"prompt": "Secret",
|
||||
"password": True,
|
||||
"help": "The secret from your WeCom AI Bot.",
|
||||
},
|
||||
{
|
||||
"name": "WECOM_ALLOWED_USERS",
|
||||
"prompt": "Allowed user IDs (comma-separated, or empty)",
|
||||
"password": False,
|
||||
"is_allowlist": True,
|
||||
"help": "Restrict which WeCom users can interact with the bot.",
|
||||
},
|
||||
{
|
||||
"name": "WECOM_HOME_CHANNEL",
|
||||
"prompt": "Home chat ID (optional, for cron/notifications)",
|
||||
"password": False,
|
||||
"help": "Chat ID for scheduled results and notifications.",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
"key": "wecom_callback",
|
||||
"label": "WeCom Callback (Self-Built App)",
|
||||
"emoji": "💬",
|
||||
"token_var": "WECOM_CALLBACK_CORP_ID",
|
||||
"setup_instructions": [
|
||||
"1. Go to WeCom Admin Console → Applications → Create Self-Built App",
|
||||
"2. Note the Corp ID (top of admin console) and create a Corp Secret",
|
||||
"3. Under Receive Messages, configure the callback URL to point to your server",
|
||||
"4. Copy the Token and EncodingAESKey from the callback configuration",
|
||||
"5. The adapter runs an HTTP server — ensure the port is reachable from WeCom",
|
||||
"6. Restrict access with WECOM_CALLBACK_ALLOWED_USERS for production use",
|
||||
],
|
||||
"vars": [
|
||||
{
|
||||
"name": "WECOM_CALLBACK_CORP_ID",
|
||||
"prompt": "Corp ID",
|
||||
"password": False,
|
||||
"help": "Your WeCom enterprise Corp ID.",
|
||||
},
|
||||
{
|
||||
"name": "WECOM_CALLBACK_CORP_SECRET",
|
||||
"prompt": "Corp Secret",
|
||||
"password": True,
|
||||
"help": "The secret for your self-built application.",
|
||||
},
|
||||
{
|
||||
"name": "WECOM_CALLBACK_AGENT_ID",
|
||||
"prompt": "Agent ID",
|
||||
"password": False,
|
||||
"help": "The Agent ID of your self-built application.",
|
||||
},
|
||||
{
|
||||
"name": "WECOM_CALLBACK_TOKEN",
|
||||
"prompt": "Callback Token",
|
||||
"password": True,
|
||||
"help": "The Token from your WeCom callback configuration.",
|
||||
},
|
||||
{
|
||||
"name": "WECOM_CALLBACK_ENCODING_AES_KEY",
|
||||
"prompt": "Encoding AES Key",
|
||||
"password": True,
|
||||
"help": "The EncodingAESKey from your WeCom callback configuration.",
|
||||
},
|
||||
{
|
||||
"name": "WECOM_CALLBACK_PORT",
|
||||
"prompt": "Callback server port (default: 8645)",
|
||||
"password": False,
|
||||
"help": "Port for the HTTP callback server.",
|
||||
},
|
||||
{
|
||||
"name": "WECOM_CALLBACK_ALLOWED_USERS",
|
||||
"prompt": "Allowed user IDs (comma-separated, or empty)",
|
||||
"password": False,
|
||||
"is_allowlist": True,
|
||||
"help": "Restrict which WeCom users can interact with the app.",
|
||||
},
|
||||
],
|
||||
},
|
||||
# Email and SMS moved to plugins/platforms/{email,sms}/ — setup metadata
|
||||
# discovered dynamically via the platform registry entries registered by
|
||||
# plugins/platforms/{email,sms}/adapter.py::register(). #41112.
|
||||
{
|
||||
"key": "weixin",
|
||||
"label": "Weixin / WeChat",
|
||||
|
|
@ -4835,6 +4448,11 @@ def _all_platforms() -> list[dict]:
|
|||
for entry in platform_registry.all_entries():
|
||||
if entry.name in by_key:
|
||||
continue # built-in already covers it
|
||||
# Drop platforms that can't function on this host. Matrix is hidden on
|
||||
# Windows (python-olm has no Windows wheel) — applies whether matrix is
|
||||
# a built-in or, post-#41112, a registry-discovered plugin.
|
||||
if sys.platform == "win32" and entry.name == "matrix":
|
||||
continue
|
||||
platforms.append(
|
||||
{
|
||||
"key": entry.name,
|
||||
|
|
@ -5122,197 +4740,13 @@ def _setup_standard_platform(platform: dict):
|
|||
print_success(f"{emoji} {label} configured!")
|
||||
|
||||
|
||||
def _setup_whatsapp():
|
||||
"""Delegate to the existing WhatsApp setup flow."""
|
||||
from hermes_cli.main import cmd_whatsapp
|
||||
import argparse
|
||||
|
||||
cmd_whatsapp(argparse.Namespace())
|
||||
# _setup_whatsapp and _setup_dingtalk moved into their plugins:
|
||||
# plugins/platforms/{whatsapp,dingtalk}/adapter.py::interactive_setup
|
||||
# (registered via setup_fn, dispatched through the plugin path). #41112.
|
||||
|
||||
|
||||
def _setup_dingtalk():
|
||||
"""Configure DingTalk — QR scan (recommended) or manual credential entry."""
|
||||
from hermes_cli.setup import (
|
||||
prompt_choice,
|
||||
prompt_yes_no,
|
||||
print_success,
|
||||
print_warning,
|
||||
)
|
||||
|
||||
dingtalk_platform = next(p for p in _PLATFORMS if p["key"] == "dingtalk")
|
||||
emoji = dingtalk_platform["emoji"]
|
||||
label = dingtalk_platform["label"]
|
||||
|
||||
print()
|
||||
print(color(f" ─── {emoji} {label} Setup ───", Colors.CYAN))
|
||||
|
||||
existing = get_env_value("DINGTALK_CLIENT_ID")
|
||||
if existing:
|
||||
print()
|
||||
print_success(f"{label} is already configured (Client ID: {existing}).")
|
||||
if not prompt_yes_no(f" Reconfigure {label}?", False):
|
||||
return
|
||||
|
||||
print()
|
||||
method = prompt_choice(
|
||||
" Choose setup method",
|
||||
[
|
||||
"QR Code Scan (Recommended, auto-obtain Client ID and Client Secret)",
|
||||
"Manual Input (Client ID and Client Secret)",
|
||||
],
|
||||
default=0,
|
||||
)
|
||||
|
||||
if method == 0:
|
||||
# ── QR-code device-flow authorization ──
|
||||
try:
|
||||
from hermes_cli.dingtalk_auth import dingtalk_qr_auth
|
||||
except ImportError as exc:
|
||||
print_warning(
|
||||
f" QR auth module failed to load ({exc}), falling back to manual input."
|
||||
)
|
||||
_setup_standard_platform(dingtalk_platform)
|
||||
return
|
||||
|
||||
result = dingtalk_qr_auth()
|
||||
if result is None:
|
||||
print_warning(" QR auth incomplete, falling back to manual input.")
|
||||
_setup_standard_platform(dingtalk_platform)
|
||||
return
|
||||
|
||||
client_id, client_secret = result
|
||||
save_env_value("DINGTALK_CLIENT_ID", client_id)
|
||||
save_env_value("DINGTALK_CLIENT_SECRET", client_secret)
|
||||
print()
|
||||
print_success(f"{emoji} {label} configured via QR scan!")
|
||||
else:
|
||||
# ── Manual entry ──
|
||||
_setup_standard_platform(dingtalk_platform)
|
||||
|
||||
|
||||
def _setup_wecom():
|
||||
"""Interactive setup for WeCom — scan QR code or manual credential input."""
|
||||
print()
|
||||
print(color(" ─── 💬 WeCom (Enterprise WeChat) Setup ───", Colors.CYAN))
|
||||
|
||||
existing_bot_id = get_env_value("WECOM_BOT_ID")
|
||||
existing_secret = get_env_value("WECOM_SECRET")
|
||||
if existing_bot_id and existing_secret:
|
||||
print()
|
||||
print_success("WeCom is already configured.")
|
||||
if not prompt_yes_no(" Reconfigure WeCom?", False):
|
||||
return
|
||||
|
||||
# ── Choose setup method ──
|
||||
print()
|
||||
method_choices = [
|
||||
"Scan QR code to obtain Bot ID and Secret automatically (recommended)",
|
||||
"Enter existing Bot ID and Secret manually",
|
||||
]
|
||||
method_idx = prompt_choice(
|
||||
" How would you like to set up WeCom?", method_choices, 0
|
||||
)
|
||||
|
||||
bot_id = None
|
||||
secret = None
|
||||
|
||||
if method_idx == 0:
|
||||
# ── QR scan flow ──
|
||||
try:
|
||||
from gateway.platforms.wecom import qr_scan_for_bot_info
|
||||
except Exception as exc:
|
||||
print_error(f" WeCom QR scan import failed: {exc}")
|
||||
qr_scan_for_bot_info = None
|
||||
|
||||
if qr_scan_for_bot_info is not None:
|
||||
try:
|
||||
credentials = qr_scan_for_bot_info()
|
||||
except KeyboardInterrupt:
|
||||
print()
|
||||
print_warning(" WeCom setup cancelled.")
|
||||
return
|
||||
except Exception as exc:
|
||||
print_warning(f" QR scan failed: {exc}")
|
||||
credentials = None
|
||||
if credentials:
|
||||
bot_id = credentials.get("bot_id", "")
|
||||
secret = credentials.get("secret", "")
|
||||
print_success(" ✔ QR scan successful! Bot ID and Secret obtained.")
|
||||
|
||||
if not bot_id or not secret:
|
||||
print_info(" QR scan did not complete. Continuing with manual input.")
|
||||
bot_id = None
|
||||
secret = None
|
||||
|
||||
# ── Manual credential input ──
|
||||
if not bot_id or not secret:
|
||||
print()
|
||||
print_info(
|
||||
" 1. Go to WeCom Application → Workspace → Smart Robot -> Create smart robots"
|
||||
)
|
||||
print_info(" 2. Select API Mode")
|
||||
print_info(" 3. Copy the Bot ID and Secret from the bot's credentials info")
|
||||
print_info(" 4. The bot connects via WebSocket — no public endpoint needed")
|
||||
print()
|
||||
bot_id = prompt(" Bot ID", password=False)
|
||||
if not bot_id:
|
||||
print_warning(" Skipped — WeCom won't work without a Bot ID.")
|
||||
return
|
||||
secret = prompt(" Secret", password=True)
|
||||
if not secret:
|
||||
print_warning(" Skipped — WeCom won't work without a Secret.")
|
||||
return
|
||||
|
||||
# ── Save core credentials ──
|
||||
save_env_value("WECOM_BOT_ID", bot_id)
|
||||
save_env_value("WECOM_SECRET", secret)
|
||||
|
||||
# ── Allowed users (deny-by-default security) ──
|
||||
print()
|
||||
print_info(" The gateway DENIES all users by default for security.")
|
||||
print_info(" Enter user IDs to create an allowlist, or leave empty.")
|
||||
allowed = prompt(" Allowed user IDs (comma-separated, or empty)", password=False)
|
||||
if allowed:
|
||||
cleaned = allowed.replace(" ", "")
|
||||
save_env_value("WECOM_ALLOWED_USERS", cleaned)
|
||||
print_success(" Saved — only these users can interact with the bot.")
|
||||
else:
|
||||
print()
|
||||
access_choices = [
|
||||
"Enable open access (anyone can message the bot)",
|
||||
"Use DM pairing (unknown users request access, you approve with 'hermes pairing approve')",
|
||||
"Disable direct messages",
|
||||
"Skip for now (bot will deny all users until configured)",
|
||||
]
|
||||
access_idx = prompt_choice(
|
||||
" How should unauthorized users be handled?", access_choices, 1
|
||||
)
|
||||
if access_idx == 0:
|
||||
save_env_value("WECOM_DM_POLICY", "open")
|
||||
save_env_value("GATEWAY_ALLOW_ALL_USERS", "true")
|
||||
print_warning(" Open access enabled — anyone can use your bot!")
|
||||
elif access_idx == 1:
|
||||
save_env_value("WECOM_DM_POLICY", "pairing")
|
||||
print_success(
|
||||
" DM pairing mode — users will receive a code to request access."
|
||||
)
|
||||
print_info(" Approve with: hermes pairing approve <platform> <code>")
|
||||
elif access_idx == 2:
|
||||
save_env_value("WECOM_DM_POLICY", "disabled")
|
||||
print_warning(" Direct messages disabled.")
|
||||
else:
|
||||
print_info(" Skipped — configure later with 'hermes gateway setup'")
|
||||
|
||||
# ── Home channel (optional) ──
|
||||
print()
|
||||
print_info(" Chat ID for scheduled results and notifications.")
|
||||
home = prompt(" Home chat ID (optional, for cron/notifications)", password=False)
|
||||
if home:
|
||||
save_env_value("WECOM_HOME_CHANNEL", home)
|
||||
print_success(f" Home channel set to {home}")
|
||||
|
||||
print()
|
||||
print_success("💬 WeCom configured!")
|
||||
# _setup_wecom moved to plugins/platforms/wecom/adapter.py::interactive_setup
|
||||
# (registered via setup_fn, dispatched through the plugin path). #41112.
|
||||
|
||||
|
||||
def _is_service_installed() -> bool:
|
||||
|
|
@ -5555,197 +4989,8 @@ def _setup_weixin():
|
|||
print_info(f" User ID: {user_id}")
|
||||
|
||||
|
||||
def _setup_feishu():
|
||||
"""Interactive setup for Feishu / Lark — scan-to-create or manual credentials."""
|
||||
print()
|
||||
print(color(" ─── 🪽 Feishu / Lark Setup ───", Colors.CYAN))
|
||||
|
||||
existing_app_id = get_env_value("FEISHU_APP_ID")
|
||||
existing_secret = get_env_value("FEISHU_APP_SECRET")
|
||||
if existing_app_id and existing_secret:
|
||||
print()
|
||||
print_success("Feishu / Lark is already configured.")
|
||||
if not prompt_yes_no(" Reconfigure Feishu / Lark?", False):
|
||||
return
|
||||
|
||||
# ── Choose setup method ──
|
||||
print()
|
||||
method_choices = [
|
||||
"Scan QR code to create a new bot automatically (recommended)",
|
||||
"Enter existing App ID and App Secret manually",
|
||||
]
|
||||
method_idx = prompt_choice(
|
||||
" How would you like to set up Feishu / Lark?", method_choices, 0
|
||||
)
|
||||
|
||||
credentials = None
|
||||
used_qr = False
|
||||
|
||||
if method_idx == 0:
|
||||
# ── QR scan-to-create ──
|
||||
try:
|
||||
from gateway.platforms.feishu import qr_register
|
||||
except Exception as exc:
|
||||
print_error(f" Feishu / Lark onboard import failed: {exc}")
|
||||
qr_register = None
|
||||
|
||||
if qr_register is not None:
|
||||
try:
|
||||
credentials = qr_register()
|
||||
except KeyboardInterrupt:
|
||||
print()
|
||||
print_warning(" Feishu / Lark setup cancelled.")
|
||||
return
|
||||
except Exception as exc:
|
||||
print_warning(f" QR registration failed: {exc}")
|
||||
if credentials:
|
||||
used_qr = True
|
||||
if not credentials:
|
||||
print_info(" QR setup did not complete. Continuing with manual input.")
|
||||
|
||||
# ── Manual credential input ──
|
||||
if not credentials:
|
||||
print()
|
||||
print_info(
|
||||
" Go to https://open.feishu.cn/ (or https://open.larksuite.com/ for Lark)"
|
||||
)
|
||||
print_info(
|
||||
" Create an app, enable the Bot capability, and copy the credentials."
|
||||
)
|
||||
print()
|
||||
app_id = prompt(" App ID", password=False)
|
||||
if not app_id:
|
||||
print_warning(" Skipped — Feishu / Lark won't work without an App ID.")
|
||||
return
|
||||
app_secret = prompt(" App Secret", password=True)
|
||||
if not app_secret:
|
||||
print_warning(" Skipped — Feishu / Lark won't work without an App Secret.")
|
||||
return
|
||||
|
||||
domain_choices = ["feishu (China)", "lark (International)"]
|
||||
domain_idx = prompt_choice(" Domain", domain_choices, 0)
|
||||
domain = "lark" if domain_idx == 1 else "feishu"
|
||||
|
||||
# Try to probe the bot with manual credentials
|
||||
bot_name = None
|
||||
try:
|
||||
from gateway.platforms.feishu import probe_bot
|
||||
|
||||
bot_info = probe_bot(app_id, app_secret, domain)
|
||||
if bot_info:
|
||||
bot_name = bot_info.get("bot_name")
|
||||
print_success(f" Credentials verified — bot: {bot_name or 'unnamed'}")
|
||||
else:
|
||||
print_warning(
|
||||
" Could not verify bot connection. Credentials saved anyway."
|
||||
)
|
||||
except Exception as exc:
|
||||
print_warning(f" Credential verification skipped: {exc}")
|
||||
|
||||
credentials = {
|
||||
"app_id": app_id,
|
||||
"app_secret": app_secret,
|
||||
"domain": domain,
|
||||
"open_id": None,
|
||||
"bot_name": bot_name,
|
||||
}
|
||||
|
||||
# ── Save core credentials ──
|
||||
app_id = credentials["app_id"]
|
||||
app_secret = credentials["app_secret"]
|
||||
domain = credentials.get("domain", "feishu")
|
||||
open_id = credentials.get("open_id")
|
||||
bot_name = credentials.get("bot_name")
|
||||
|
||||
save_env_value("FEISHU_APP_ID", app_id)
|
||||
save_env_value("FEISHU_APP_SECRET", app_secret)
|
||||
save_env_value("FEISHU_DOMAIN", domain)
|
||||
# Bot identity is resolved at runtime via _hydrate_bot_identity().
|
||||
|
||||
# ── Connection mode ──
|
||||
if used_qr:
|
||||
connection_mode = "websocket"
|
||||
else:
|
||||
print()
|
||||
mode_choices = [
|
||||
"WebSocket (recommended — no public URL needed)",
|
||||
"Webhook (requires a reachable HTTP endpoint)",
|
||||
]
|
||||
mode_idx = prompt_choice(" Connection mode", mode_choices, 0)
|
||||
connection_mode = "webhook" if mode_idx == 1 else "websocket"
|
||||
if connection_mode == "webhook":
|
||||
print_info(" Webhook defaults: 127.0.0.1:8765/feishu/webhook")
|
||||
print_info(
|
||||
" Override with FEISHU_WEBHOOK_HOST / FEISHU_WEBHOOK_PORT / FEISHU_WEBHOOK_PATH"
|
||||
)
|
||||
print_info(
|
||||
" For signature verification, set FEISHU_ENCRYPT_KEY and FEISHU_VERIFICATION_TOKEN"
|
||||
)
|
||||
save_env_value("FEISHU_CONNECTION_MODE", connection_mode)
|
||||
|
||||
if bot_name:
|
||||
print()
|
||||
print_success(f" Bot created: {bot_name}")
|
||||
|
||||
# ── DM security policy ──
|
||||
print()
|
||||
access_choices = [
|
||||
"Use DM pairing approval (recommended)",
|
||||
"Allow all direct messages",
|
||||
"Only allow listed user IDs",
|
||||
]
|
||||
access_idx = prompt_choice(
|
||||
" How should direct messages be authorized?", access_choices, 0
|
||||
)
|
||||
if access_idx == 0:
|
||||
save_env_value("FEISHU_ALLOW_ALL_USERS", "false")
|
||||
save_env_value("FEISHU_ALLOWED_USERS", "")
|
||||
print_success(" DM pairing enabled.")
|
||||
print_info(
|
||||
" Unknown users can request access; approve with `hermes pairing approve`."
|
||||
)
|
||||
elif access_idx == 1:
|
||||
save_env_value("FEISHU_ALLOW_ALL_USERS", "true")
|
||||
save_env_value("FEISHU_ALLOWED_USERS", "")
|
||||
print_warning(" Open DM access enabled for Feishu / Lark.")
|
||||
else:
|
||||
save_env_value("FEISHU_ALLOW_ALL_USERS", "false")
|
||||
default_allow = open_id or ""
|
||||
allowlist = prompt(
|
||||
" Allowed user IDs (comma-separated)", default_allow, password=False
|
||||
).replace(" ", "")
|
||||
save_env_value("FEISHU_ALLOWED_USERS", allowlist)
|
||||
print_success(" Allowlist saved.")
|
||||
|
||||
# ── Group policy ──
|
||||
print()
|
||||
group_choices = [
|
||||
"Respond only when @mentioned in groups (recommended)",
|
||||
"Disable group chats",
|
||||
]
|
||||
group_idx = prompt_choice(" How should group chats be handled?", group_choices, 0)
|
||||
if group_idx == 0:
|
||||
save_env_value("FEISHU_GROUP_POLICY", "open")
|
||||
print_info(" Group chats enabled (bot must be @mentioned).")
|
||||
else:
|
||||
save_env_value("FEISHU_GROUP_POLICY", "disabled")
|
||||
print_info(" Group chats disabled.")
|
||||
|
||||
# ── Home channel ──
|
||||
print()
|
||||
home_channel = prompt(
|
||||
" Home chat ID (optional, for cron/notifications)", password=False
|
||||
)
|
||||
if home_channel:
|
||||
save_env_value("FEISHU_HOME_CHANNEL", home_channel)
|
||||
print_success(f" Home channel set to {home_channel}")
|
||||
|
||||
print()
|
||||
print_success("🪽 Feishu / Lark configured!")
|
||||
print_info(f" App ID: {app_id}")
|
||||
print_info(f" Domain: {domain}")
|
||||
if bot_name:
|
||||
print_info(f" Bot: {bot_name}")
|
||||
# _setup_feishu moved to plugins/platforms/feishu/adapter.py::interactive_setup
|
||||
# (registered via setup_fn, dispatched through the plugin path). #41112.
|
||||
|
||||
|
||||
def _setup_qqbot():
|
||||
|
|
@ -6014,23 +5259,31 @@ def _builtin_setup_fn(key: str):
|
|||
from hermes_cli import setup as _s
|
||||
|
||||
return {
|
||||
"telegram": _s._setup_telegram,
|
||||
# telegram moved into the plugin: setup_fn registered by
|
||||
# plugins/platforms/telegram/adapter.py::register(). #41112.
|
||||
# discord moved into the plugin: setup_fn is registered by
|
||||
# plugins/platforms/discord/adapter.py::register() and dispatched
|
||||
# via the plugin path in _configure_platform().
|
||||
"slack": _s._setup_slack,
|
||||
"matrix": _s._setup_matrix,
|
||||
# slack moved into the plugin: setup_fn is registered by
|
||||
# plugins/platforms/slack/adapter.py::register() and dispatched
|
||||
# via the plugin path in _configure_platform(). #41112.
|
||||
# matrix moved into the plugin: setup_fn registered by
|
||||
# plugins/platforms/matrix/adapter.py::register() and dispatched via
|
||||
# the plugin path in _configure_platform(). #41112.
|
||||
# mattermost moved into the plugin: setup_fn is registered by
|
||||
# plugins/platforms/mattermost/adapter.py::register() and dispatched
|
||||
# via the plugin path in _configure_platform().
|
||||
"bluebubbles": _s._setup_bluebubbles,
|
||||
"webhooks": _s._setup_webhooks,
|
||||
"signal": _setup_signal,
|
||||
"whatsapp": _setup_whatsapp,
|
||||
# whatsapp + dingtalk moved into plugins: setup_fn registered by
|
||||
# plugins/platforms/{whatsapp,dingtalk}/adapter.py::register() and
|
||||
# dispatched via the plugin path in _configure_platform(). #41112.
|
||||
"weixin": _setup_weixin,
|
||||
"dingtalk": _setup_dingtalk,
|
||||
"feishu": _setup_feishu,
|
||||
"wecom": _setup_wecom,
|
||||
# feishu moved into the plugin: setup_fn registered by
|
||||
# plugins/platforms/feishu/adapter.py::register(). #41112.
|
||||
# wecom moved into the plugin: setup_fn registered by
|
||||
# plugins/platforms/wecom/adapter.py::register(). #41112.
|
||||
"qqbot": _setup_qqbot,
|
||||
}.get(key)
|
||||
|
||||
|
|
|
|||
|
|
@ -1800,231 +1800,13 @@ def _setup_telegram():
|
|||
save_env_value("TELEGRAM_HOME_CHANNEL", home_channel)
|
||||
|
||||
|
||||
def _setup_slack():
|
||||
"""Configure Slack bot credentials."""
|
||||
print_header("Slack")
|
||||
existing = get_env_value("SLACK_BOT_TOKEN")
|
||||
if existing:
|
||||
print_info("Slack: already configured")
|
||||
if not prompt_yes_no("Reconfigure Slack?", False):
|
||||
# Even without reconfiguring, offer to refresh the manifest so
|
||||
# new commands (e.g. /btw, /stop, ...) get registered in Slack.
|
||||
if prompt_yes_no(
|
||||
"Regenerate the Slack app manifest with the latest command "
|
||||
"list? (recommended after `hermes update`)",
|
||||
True,
|
||||
):
|
||||
_write_slack_manifest_and_instruct()
|
||||
return
|
||||
|
||||
print_info("Steps to create a Slack app:")
|
||||
print_info(" 1. Go to https://api.slack.com/apps → Create New App")
|
||||
print_info(" Pick 'From an app manifest' — we'll generate one for you below.")
|
||||
print_info(" 2. Enable Socket Mode: Settings → Socket Mode → Enable")
|
||||
print_info(" • Create an App-Level Token with 'connections:write' scope")
|
||||
print_info(" 3. Install to Workspace: Settings → Install App")
|
||||
print_info(" 4. After installing, invite the bot to channels: /invite @YourBot")
|
||||
print()
|
||||
print_info(" Full guide: https://hermes-agent.nousresearch.com/docs/user-guide/messaging/slack/")
|
||||
print()
|
||||
|
||||
# Generate and write manifest up-front so the user can paste it into
|
||||
# the "Create from manifest" flow instead of clicking through scopes /
|
||||
# events / slash commands one at a time.
|
||||
_write_slack_manifest_and_instruct()
|
||||
|
||||
print()
|
||||
bot_token = prompt("Slack Bot Token (xoxb-...)", password=True)
|
||||
if not bot_token:
|
||||
return
|
||||
save_env_value("SLACK_BOT_TOKEN", bot_token)
|
||||
app_token = prompt("Slack App Token (xapp-...)", password=True)
|
||||
if app_token:
|
||||
save_env_value("SLACK_APP_TOKEN", app_token)
|
||||
print_success("Slack tokens saved")
|
||||
|
||||
print()
|
||||
print_info("🔒 Security: Restrict who can use your bot")
|
||||
print_info(" To find a Member ID: click a user's name → View full profile → ⋮ → Copy member ID")
|
||||
print()
|
||||
allowed_users = prompt(
|
||||
"Allowed user IDs (comma-separated, leave empty to deny everyone except paired users)"
|
||||
)
|
||||
if allowed_users:
|
||||
save_env_value("SLACK_ALLOWED_USERS", allowed_users.replace(" ", ""))
|
||||
print_success("Slack allowlist configured")
|
||||
else:
|
||||
print_warning("⚠️ No Slack allowlist set - unpaired users will be denied by default.")
|
||||
print_info(" Set SLACK_ALLOW_ALL_USERS=true or GATEWAY_ALLOW_ALL_USERS=true only if you intentionally want open workspace access.")
|
||||
|
||||
print()
|
||||
print_info("📬 Home Channel: where Hermes delivers cron job results,")
|
||||
print_info(" cross-platform messages, and notifications.")
|
||||
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)")
|
||||
if home_channel:
|
||||
save_env_value("SLACK_HOME_CHANNEL", home_channel.strip())
|
||||
# _setup_slack and _write_slack_manifest_and_instruct moved to the slack
|
||||
# plugin: plugins/platforms/slack/adapter.py::interactive_setup (registered
|
||||
# via setup_fn and dispatched through the plugin path). #41112 / #3823.
|
||||
|
||||
|
||||
def _write_slack_manifest_and_instruct():
|
||||
"""Generate the Slack manifest, write it under HERMES_HOME, and print
|
||||
paste-into-Slack instructions.
|
||||
|
||||
Exposed as its own helper so both the initial setup flow and the
|
||||
"reconfigure? → no" branch can refresh the manifest without the user
|
||||
re-entering tokens. Failures are non-fatal — if the manifest write
|
||||
fails for any reason, we print a warning and skip rather than abort
|
||||
the whole Slack setup.
|
||||
"""
|
||||
try:
|
||||
from hermes_cli.slack_cli import _build_full_manifest
|
||||
from hermes_constants import get_hermes_home
|
||||
|
||||
manifest = _build_full_manifest(
|
||||
bot_name="Hermes",
|
||||
bot_description="Your Hermes agent on Slack",
|
||||
)
|
||||
target = Path(get_hermes_home()) / "slack-manifest.json"
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
import json as _json
|
||||
target.write_text(
|
||||
_json.dumps(manifest, indent=2, ensure_ascii=False) + "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
print_success(f"Slack app manifest written to: {target}")
|
||||
print_info(
|
||||
" Paste it into https://api.slack.com/apps → your app → Features "
|
||||
"→ App Manifest → Edit, then Save. Slack will prompt to "
|
||||
"reinstall if scopes or slash commands changed."
|
||||
)
|
||||
print_info(
|
||||
" Re-run `hermes slack manifest --write` anytime to refresh after "
|
||||
"Hermes adds new commands."
|
||||
)
|
||||
except Exception as exc: # pragma: no cover - best-effort UX helper
|
||||
print_warning(f"Couldn't write Slack manifest: {exc}")
|
||||
print_info(
|
||||
" You can generate it manually later with: "
|
||||
"hermes slack manifest --write"
|
||||
)
|
||||
|
||||
|
||||
def _setup_matrix():
|
||||
"""Configure Matrix credentials."""
|
||||
print_header("Matrix")
|
||||
existing = get_env_value("MATRIX_ACCESS_TOKEN") or get_env_value("MATRIX_PASSWORD")
|
||||
if existing:
|
||||
print_info("Matrix: already configured")
|
||||
if not prompt_yes_no("Reconfigure Matrix?", False):
|
||||
return
|
||||
|
||||
print_info("Works with any Matrix homeserver (Synapse, Conduit, Dendrite, or matrix.org).")
|
||||
print_info(" 1. Create a bot user on your homeserver, or use your own account")
|
||||
print_info(" 2. Get an access token from Element, or provide user ID + password")
|
||||
print()
|
||||
homeserver = prompt("Homeserver URL (e.g. https://matrix.example.org)")
|
||||
if homeserver:
|
||||
save_env_value("MATRIX_HOMESERVER", homeserver.rstrip("/"))
|
||||
|
||||
print()
|
||||
print_info("Auth: provide an access token (recommended), or user ID + password.")
|
||||
token = prompt("Access token (leave empty for password login)", password=True)
|
||||
if token:
|
||||
save_env_value("MATRIX_ACCESS_TOKEN", token)
|
||||
user_id = prompt("User ID (@bot:server — optional, will be auto-detected)")
|
||||
if user_id:
|
||||
save_env_value("MATRIX_USER_ID", user_id)
|
||||
print_success("Matrix access token saved")
|
||||
else:
|
||||
user_id = prompt("User ID (@bot:server)")
|
||||
if user_id:
|
||||
save_env_value("MATRIX_USER_ID", user_id)
|
||||
password = prompt("Password", password=True)
|
||||
if password:
|
||||
save_env_value("MATRIX_PASSWORD", password)
|
||||
print_success("Matrix credentials saved")
|
||||
|
||||
if token or get_env_value("MATRIX_PASSWORD"):
|
||||
print()
|
||||
want_e2ee = prompt_yes_no("Enable end-to-end encryption (E2EE)?", False)
|
||||
if want_e2ee:
|
||||
save_env_value("MATRIX_ENCRYPTION", "true")
|
||||
print_success("E2EE enabled")
|
||||
|
||||
matrix_pkg = "mautrix[encryption]" if want_e2ee else "mautrix"
|
||||
# Use the central lazy-deps feature group so we install ALL of
|
||||
# platform.matrix's dependencies (mautrix, Markdown, aiosqlite,
|
||||
# asyncpg, aiohttp-socks) — not just mautrix itself. The previous
|
||||
# hand-rolled ``pip install mautrix[encryption]`` left asyncpg /
|
||||
# aiosqlite uninstalled and broke E2EE connect with
|
||||
# ``No module named 'asyncpg'`` on every fresh install (#31116).
|
||||
try:
|
||||
from tools.lazy_deps import ensure as _lazy_ensure, feature_missing
|
||||
_missing_before = feature_missing("platform.matrix")
|
||||
if _missing_before:
|
||||
print_info(
|
||||
f"Installing {matrix_pkg} (+ {len(_missing_before)} runtime deps)..."
|
||||
)
|
||||
try:
|
||||
_lazy_ensure("platform.matrix", prompt=False)
|
||||
print_success(f"{matrix_pkg} installed")
|
||||
except Exception as exc:
|
||||
print_warning(
|
||||
f"Install failed — run manually: pip install "
|
||||
f"'mautrix[encryption]' asyncpg aiosqlite Markdown "
|
||||
f"aiohttp-socks"
|
||||
)
|
||||
print_info(f" Error: {exc}")
|
||||
except ImportError:
|
||||
# tools.lazy_deps unavailable (extreme edge case — partial
|
||||
# install). Fall back to the legacy single-package install
|
||||
# path so the wizard still does *something*.
|
||||
try:
|
||||
__import__("mautrix")
|
||||
except ImportError:
|
||||
print_info(f"Installing {matrix_pkg}...")
|
||||
import subprocess
|
||||
uv_bin = shutil.which("uv")
|
||||
if uv_bin:
|
||||
result = subprocess.run(
|
||||
[uv_bin, "pip", "install", "--python", sys.executable, matrix_pkg],
|
||||
capture_output=True, text=True,
|
||||
)
|
||||
else:
|
||||
result = subprocess.run(
|
||||
[sys.executable, "-m", "pip", "install", matrix_pkg],
|
||||
capture_output=True, text=True,
|
||||
)
|
||||
if result.returncode == 0:
|
||||
print_success(f"{matrix_pkg} installed")
|
||||
else:
|
||||
print_warning(
|
||||
f"Install failed — run manually: pip install "
|
||||
f"'{matrix_pkg}' asyncpg aiosqlite Markdown aiohttp-socks"
|
||||
)
|
||||
if result.stderr:
|
||||
print_info(f" Error: {result.stderr.strip().splitlines()[-1]}")
|
||||
|
||||
print()
|
||||
print_info("🔒 Security: Restrict who can use your bot")
|
||||
print_info(" Matrix user IDs look like @username:server")
|
||||
print()
|
||||
allowed_users = prompt("Allowed user IDs (comma-separated, leave empty for open access)")
|
||||
if allowed_users:
|
||||
save_env_value("MATRIX_ALLOWED_USERS", allowed_users.replace(" ", ""))
|
||||
print_success("Matrix allowlist configured")
|
||||
else:
|
||||
print_info("⚠️ No allowlist set - anyone who can message the bot can use it!")
|
||||
|
||||
print()
|
||||
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)")
|
||||
if home_room:
|
||||
save_env_value("MATRIX_HOME_ROOM", home_room)
|
||||
# _setup_matrix moved to plugins/platforms/matrix/adapter.py::interactive_setup
|
||||
# (registered via setup_fn, dispatched through the plugin path). #41112.
|
||||
|
||||
|
||||
def _setup_bluebubbles():
|
||||
|
|
|
|||
|
|
@ -210,7 +210,11 @@ class _ComponentFilter(logging.Filter):
|
|||
# Logger name prefixes that belong to each component.
|
||||
# Used by _ComponentFilter and exposed for ``hermes logs --component``.
|
||||
COMPONENT_PREFIXES = {
|
||||
"gateway": ("gateway", "hermes_plugins"),
|
||||
# ``plugins.platforms`` covers messaging-platform adapters that migrated
|
||||
# out of ``gateway/platforms/`` into bundled plugins (#41112) — they are
|
||||
# still gateway components and their logs belong in gateway.log / match
|
||||
# ``hermes logs --component gateway``.
|
||||
"gateway": ("gateway", "hermes_plugins", "plugins.platforms"),
|
||||
"agent": ("agent", "run_agent", "model_tools", "batch_runner"),
|
||||
"tools": ("tools",),
|
||||
"cli": ("hermes_cli", "cli"),
|
||||
|
|
|
|||
3
plugins/platforms/dingtalk/__init__.py
Normal file
3
plugins/platforms/dingtalk/__init__.py
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
from .adapter import register
|
||||
|
||||
__all__ = ["register"]
|
||||
|
|
@ -42,7 +42,7 @@ try:
|
|||
from dingtalk_stream.frames import CallbackMessage, AckMessage
|
||||
|
||||
DINGTALK_STREAM_AVAILABLE = True
|
||||
except ImportError:
|
||||
except Exception: # noqa: BLE001 — broad: optional SDK's transitive deps (cryptography) may raise non-ImportError; degrade gracefully (#41112)
|
||||
DINGTALK_STREAM_AVAILABLE = False
|
||||
dingtalk_stream = None # type: ignore[assignment]
|
||||
ChatbotMessage = None # type: ignore[assignment]
|
||||
|
|
@ -64,7 +64,14 @@ except ImportError:
|
|||
HTTPX_AVAILABLE = False
|
||||
httpx = None # type: ignore[assignment]
|
||||
|
||||
# Card SDK for AI Cards (following QwenPaw pattern)
|
||||
# Card SDK for AI Cards (following QwenPaw pattern).
|
||||
# Catch broad Exception, not just ImportError: the alibabacloud_dingtalk SDK
|
||||
# transitively imports cryptography and can raise AttributeError (not
|
||||
# ImportError) when the installed cryptography version skews from what the SDK
|
||||
# expects (e.g. `cryptography.utils.DeprecatedIn46` missing on older
|
||||
# cryptography). An optional SDK with a broken dependency chain must degrade
|
||||
# gracefully — same as a missing one — rather than crash the whole adapter
|
||||
# (and therefore the whole plugin) import. #41112.
|
||||
try:
|
||||
from alibabacloud_dingtalk.card_1_0 import (
|
||||
client as dingtalk_card_client,
|
||||
|
|
@ -78,7 +85,7 @@ try:
|
|||
from alibabacloud_tea_util import models as tea_util_models
|
||||
|
||||
CARD_SDK_AVAILABLE = True
|
||||
except ImportError:
|
||||
except Exception:
|
||||
CARD_SDK_AVAILABLE = False
|
||||
dingtalk_card_client = None
|
||||
dingtalk_card_models = None
|
||||
|
|
@ -129,7 +136,7 @@ def check_dingtalk_requirements() -> bool:
|
|||
from dingtalk_stream import ChatbotMessage as _CM
|
||||
from dingtalk_stream.frames import CallbackMessage as _CBM, AckMessage as _AM
|
||||
import httpx as _httpx
|
||||
except ImportError:
|
||||
except Exception:
|
||||
return False
|
||||
dingtalk_stream = _ds
|
||||
ChatbotMessage = _CM
|
||||
|
|
@ -1501,3 +1508,200 @@ class _IncomingHandler(
|
|||
logger.exception(
|
||||
"[%s] Error processing incoming message", self._adapter.name
|
||||
)
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────
|
||||
# Plugin migration glue (#41112 / #3823)
|
||||
#
|
||||
# Added when the DingTalk adapter moved from gateway/platforms/dingtalk.py into
|
||||
# this bundled plugin. Mirrors the Discord (#24356) / Slack migrations: a
|
||||
# register(ctx) entry point plus hook implementations that replace the
|
||||
# per-platform core touchpoints (the Platform.DINGTALK elif in gateway/run.py,
|
||||
# the dingtalk_cfg YAML→env block + _PLATFORM_CONNECTED_CHECKERS entry in
|
||||
# gateway/config.py, the _setup_dingtalk wizard + _PLATFORMS["dingtalk"] static
|
||||
# dict in hermes_cli/gateway.py, and the _send_dingtalk dispatch in
|
||||
# tools/send_message_tool.py).
|
||||
# ──────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
async def _standalone_send(
|
||||
pconfig,
|
||||
chat_id,
|
||||
message,
|
||||
*,
|
||||
thread_id=None,
|
||||
media_files=None,
|
||||
force_document=False,
|
||||
):
|
||||
"""Out-of-process DingTalk delivery via a static robot webhook URL.
|
||||
|
||||
Implements the standalone_sender_fn contract so deliver=dingtalk cron jobs
|
||||
succeed when cron runs separately from the gateway. The live adapter uses
|
||||
per-session webhook URLs from incoming messages, which aren't available
|
||||
out-of-process; this path uses the static DINGTALK_WEBHOOK_URL / extra
|
||||
webhook_url instead. Replaces the legacy _send_dingtalk helper.
|
||||
"""
|
||||
extra = getattr(pconfig, "extra", {}) or {}
|
||||
try:
|
||||
import httpx
|
||||
except ImportError:
|
||||
return {"error": "httpx not installed"}
|
||||
try:
|
||||
webhook_url = extra.get("webhook_url") or os.getenv("DINGTALK_WEBHOOK_URL", "")
|
||||
if not webhook_url:
|
||||
return {"error": "DingTalk not configured. Set DINGTALK_WEBHOOK_URL env var or webhook_url in dingtalk platform extra config."}
|
||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||
resp = await client.post(
|
||||
webhook_url,
|
||||
json={"msgtype": "text", "text": {"content": message}},
|
||||
)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
if data.get("errcode", 0) != 0:
|
||||
return {"error": f"DingTalk API error: {data.get('errmsg', 'unknown')}"}
|
||||
return {"success": True, "platform": "dingtalk", "chat_id": chat_id}
|
||||
except Exception as e:
|
||||
# Redact the access_token from webhook URLs that may appear in the
|
||||
# exception text. Reuse send_message_tool._error's redaction so the
|
||||
# logic stays single-sourced (lazy import avoids a circular at module
|
||||
# load). Falls back to a plain message if that helper is unavailable.
|
||||
try:
|
||||
from tools.send_message_tool import _error as _redact_error
|
||||
return _redact_error(f"DingTalk send failed: {e}")
|
||||
except Exception:
|
||||
return {"error": f"DingTalk send failed: {e}"}
|
||||
|
||||
|
||||
def interactive_setup() -> None:
|
||||
"""Configure DingTalk — QR scan (recommended) or manual credential entry.
|
||||
|
||||
Replaces hermes_cli/setup.py-era _setup_dingtalk + the static
|
||||
_PLATFORMS["dingtalk"] dict in hermes_cli/gateway.py. 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.setup import prompt_choice
|
||||
from hermes_cli.cli_output import (
|
||||
prompt,
|
||||
prompt_yes_no,
|
||||
print_header,
|
||||
print_success,
|
||||
print_warning,
|
||||
)
|
||||
|
||||
print_header("DingTalk")
|
||||
existing = get_env_value("DINGTALK_CLIENT_ID")
|
||||
if existing:
|
||||
print_success(f"DingTalk is already configured (Client ID: {existing}).")
|
||||
if not prompt_yes_no("Reconfigure DingTalk?", False):
|
||||
return
|
||||
|
||||
method = prompt_choice(
|
||||
"Choose setup method",
|
||||
[
|
||||
"QR Code Scan (Recommended, auto-obtain Client ID and Client Secret)",
|
||||
"Manual Input (Client ID and Client Secret)",
|
||||
],
|
||||
default=0,
|
||||
)
|
||||
|
||||
if method == 0:
|
||||
try:
|
||||
from hermes_cli.dingtalk_auth import dingtalk_qr_auth
|
||||
except ImportError as exc:
|
||||
print_warning(f"QR auth module failed to load ({exc}), falling back to manual input.")
|
||||
_manual_credential_entry(prompt, save_env_value, print_success)
|
||||
return
|
||||
result = dingtalk_qr_auth()
|
||||
if result is None:
|
||||
print_warning("QR auth incomplete, falling back to manual input.")
|
||||
_manual_credential_entry(prompt, save_env_value, print_success)
|
||||
return
|
||||
client_id, client_secret = result
|
||||
save_env_value("DINGTALK_CLIENT_ID", client_id)
|
||||
save_env_value("DINGTALK_CLIENT_SECRET", client_secret)
|
||||
print_success("DingTalk configured via QR scan!")
|
||||
else:
|
||||
_manual_credential_entry(prompt, save_env_value, print_success)
|
||||
|
||||
|
||||
def _manual_credential_entry(prompt, save_env_value, print_success) -> None:
|
||||
client_id = prompt("DingTalk Client ID (app key)")
|
||||
if not client_id:
|
||||
return
|
||||
save_env_value("DINGTALK_CLIENT_ID", client_id)
|
||||
client_secret = prompt("DingTalk Client Secret", password=True)
|
||||
if client_secret:
|
||||
save_env_value("DINGTALK_CLIENT_SECRET", client_secret)
|
||||
print_success("DingTalk credentials saved")
|
||||
|
||||
|
||||
def _apply_yaml_config(yaml_cfg: dict, dingtalk_cfg: dict) -> dict | None:
|
||||
"""Translate config.yaml dingtalk: keys into DINGTALK_* env vars.
|
||||
|
||||
Implements the apply_yaml_config_fn contract (#24849). Mirrors the legacy
|
||||
dingtalk_cfg block from gateway/config.py::load_gateway_config(). Env vars
|
||||
take precedence over YAML (each assignment guarded by not os.getenv(...)).
|
||||
Returns None — everything flows through env.
|
||||
"""
|
||||
import json as _json
|
||||
if "require_mention" in dingtalk_cfg and not os.getenv("DINGTALK_REQUIRE_MENTION"):
|
||||
os.environ["DINGTALK_REQUIRE_MENTION"] = str(dingtalk_cfg["require_mention"]).lower()
|
||||
if "mention_patterns" in dingtalk_cfg and not os.getenv("DINGTALK_MENTION_PATTERNS"):
|
||||
os.environ["DINGTALK_MENTION_PATTERNS"] = _json.dumps(dingtalk_cfg["mention_patterns"])
|
||||
frc = dingtalk_cfg.get("free_response_chats")
|
||||
if frc is not None and not os.getenv("DINGTALK_FREE_RESPONSE_CHATS"):
|
||||
if isinstance(frc, list):
|
||||
frc = ",".join(str(v) for v in frc)
|
||||
os.environ["DINGTALK_FREE_RESPONSE_CHATS"] = str(frc)
|
||||
ac = dingtalk_cfg.get("allowed_chats")
|
||||
if ac is not None and not os.getenv("DINGTALK_ALLOWED_CHATS"):
|
||||
if isinstance(ac, list):
|
||||
ac = ",".join(str(v) for v in ac)
|
||||
os.environ["DINGTALK_ALLOWED_CHATS"] = str(ac)
|
||||
allowed = dingtalk_cfg.get("allowed_users")
|
||||
if allowed is not None and not os.getenv("DINGTALK_ALLOWED_USERS"):
|
||||
if isinstance(allowed, list):
|
||||
allowed = ",".join(str(v) for v in allowed)
|
||||
os.environ["DINGTALK_ALLOWED_USERS"] = str(allowed)
|
||||
return None
|
||||
|
||||
|
||||
def _is_connected(config) -> bool:
|
||||
"""DingTalk is connected when client_id + client_secret are present.
|
||||
|
||||
Mirrors the legacy _PLATFORM_CONNECTED_CHECKERS[Platform.DINGTALK] entry.
|
||||
Reads from PlatformConfig.extra first, then env vars.
|
||||
"""
|
||||
extra = getattr(config, "extra", {}) or {}
|
||||
return bool(
|
||||
(extra.get("client_id") or os.getenv("DINGTALK_CLIENT_ID"))
|
||||
and (extra.get("client_secret") or os.getenv("DINGTALK_CLIENT_SECRET"))
|
||||
)
|
||||
|
||||
|
||||
def _build_adapter(config):
|
||||
"""Factory wrapper that constructs DingTalkAdapter from a PlatformConfig."""
|
||||
return DingTalkAdapter(config)
|
||||
|
||||
|
||||
def register(ctx) -> None:
|
||||
"""Plugin entry point — called by the Hermes plugin system."""
|
||||
ctx.register_platform(
|
||||
name="dingtalk",
|
||||
label="DingTalk",
|
||||
adapter_factory=_build_adapter,
|
||||
check_fn=check_dingtalk_requirements,
|
||||
is_connected=_is_connected,
|
||||
validate_config=_is_connected,
|
||||
required_env=["DINGTALK_CLIENT_ID", "DINGTALK_CLIENT_SECRET"],
|
||||
install_hint="pip install 'dingtalk-stream>=0.20' httpx",
|
||||
setup_fn=interactive_setup,
|
||||
apply_yaml_config_fn=_apply_yaml_config,
|
||||
allowed_users_env="DINGTALK_ALLOWED_USERS",
|
||||
allow_all_env="DINGTALK_ALLOW_ALL_USERS",
|
||||
cron_deliver_env_var="DINGTALK_HOME_CHANNEL",
|
||||
standalone_sender_fn=_standalone_send,
|
||||
emoji="🐳",
|
||||
allow_update_command=True,
|
||||
)
|
||||
39
plugins/platforms/dingtalk/plugin.yaml
Normal file
39
plugins/platforms/dingtalk/plugin.yaml
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
name: dingtalk-platform
|
||||
label: DingTalk
|
||||
kind: platform
|
||||
version: 1.0.0
|
||||
description: >
|
||||
DingTalk gateway adapter for Hermes Agent.
|
||||
Connects to DingTalk via the dingtalk-stream SDK (Stream Mode) and relays
|
||||
messages between DingTalk chats and the Hermes agent. Supports text, images,
|
||||
audio, video, rich text, files, group @mention gating, free-response chats,
|
||||
and per-user allowlists.
|
||||
author: NousResearch
|
||||
requires_env:
|
||||
- name: DINGTALK_CLIENT_ID
|
||||
description: "DingTalk app key (Client ID)"
|
||||
prompt: "DingTalk Client ID (app key)"
|
||||
url: "https://open-dev.dingtalk.com"
|
||||
password: false
|
||||
- name: DINGTALK_CLIENT_SECRET
|
||||
description: "DingTalk app secret (Client Secret)"
|
||||
prompt: "DingTalk Client Secret"
|
||||
url: "https://open-dev.dingtalk.com"
|
||||
password: true
|
||||
optional_env:
|
||||
- name: DINGTALK_WEBHOOK_URL
|
||||
description: "Static robot webhook URL for cross-platform / cron delivery"
|
||||
prompt: "DingTalk robot webhook URL (optional)"
|
||||
password: false
|
||||
- name: DINGTALK_ALLOWED_USERS
|
||||
description: "Comma-separated staff/sender IDs allowed to talk to the bot (* = any)"
|
||||
prompt: "Allowed users (comma-separated)"
|
||||
password: false
|
||||
- name: DINGTALK_HOME_CHANNEL
|
||||
description: "Default conversation ID for cron / notification delivery"
|
||||
prompt: "Home channel ID"
|
||||
password: false
|
||||
- name: DINGTALK_HOME_CHANNEL_NAME
|
||||
description: "Display name for the DingTalk home channel"
|
||||
prompt: "Home channel display name"
|
||||
password: false
|
||||
3
plugins/platforms/email/__init__.py
Normal file
3
plugins/platforms/email/__init__.py
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
from .adapter import register
|
||||
|
||||
__all__ = ["register"]
|
||||
|
|
@ -882,3 +882,101 @@ class EmailAdapter(BasePlatformAdapter):
|
|||
"chat_id": chat_id,
|
||||
"subject": ctx.get("subject", ""),
|
||||
}
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────
|
||||
# Plugin migration glue (#41112 / #3823)
|
||||
#
|
||||
# Added when the Email adapter moved from gateway/platforms/email.py into this
|
||||
# bundled plugin. register() exposes the platform via the registry, replacing
|
||||
# the Platform.EMAIL elif in gateway/run.py, the _PLATFORM_CONNECTED_CHECKERS
|
||||
# entry in gateway/config.py, the _PLATFORMS["email"] static dict in
|
||||
# hermes_cli/gateway.py, and the _send_email dispatch in
|
||||
# tools/send_message_tool.py. EMAIL_* env→PlatformConfig seeding stays in core.
|
||||
# ──────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
async def _standalone_send(
|
||||
pconfig,
|
||||
chat_id,
|
||||
message,
|
||||
*,
|
||||
thread_id=None,
|
||||
media_files=None,
|
||||
force_document=False,
|
||||
):
|
||||
"""Out-of-process Email delivery via SMTP (one-shot). Implements the
|
||||
standalone_sender_fn contract; replaces the legacy _send_email helper."""
|
||||
import smtplib
|
||||
import ssl as _ssl
|
||||
from email.mime.text import MIMEText
|
||||
from email.utils import formatdate
|
||||
|
||||
extra = getattr(pconfig, "extra", {}) or {}
|
||||
address = extra.get("address") or os.getenv("EMAIL_ADDRESS", "")
|
||||
password = os.getenv("EMAIL_PASSWORD", "")
|
||||
smtp_host = extra.get("smtp_host") or os.getenv("EMAIL_SMTP_HOST", "")
|
||||
try:
|
||||
smtp_port = int(os.getenv("EMAIL_SMTP_PORT", "587"))
|
||||
except (ValueError, TypeError):
|
||||
smtp_port = 587
|
||||
|
||||
if not all([address, password, smtp_host]):
|
||||
return {"error": "Email not configured (EMAIL_ADDRESS, EMAIL_PASSWORD, EMAIL_SMTP_HOST required)"}
|
||||
|
||||
try:
|
||||
msg = MIMEText(message, "plain", "utf-8")
|
||||
msg["From"] = address
|
||||
msg["To"] = chat_id
|
||||
msg["Subject"] = "Hermes Agent"
|
||||
msg["Date"] = formatdate(localtime=True)
|
||||
|
||||
server = smtplib.SMTP(smtp_host, smtp_port)
|
||||
server.starttls(context=_ssl.create_default_context())
|
||||
server.login(address, password)
|
||||
server.send_message(msg)
|
||||
server.quit()
|
||||
return {"success": True, "platform": "email", "chat_id": chat_id}
|
||||
except Exception as e:
|
||||
try:
|
||||
from tools.send_message_tool import _error as _e
|
||||
return _e(f"Email send failed: {e}")
|
||||
except Exception:
|
||||
return {"error": f"Email send failed: {e}"}
|
||||
|
||||
|
||||
def _is_connected(config) -> bool:
|
||||
"""Email is connected when an address is configured (in PlatformConfig.extra
|
||||
or via EMAIL_ADDRESS). Mirrors the legacy
|
||||
_PLATFORM_CONNECTED_CHECKERS[Platform.EMAIL] = bool(extra.get('address'))."""
|
||||
extra = getattr(config, "extra", {}) or {}
|
||||
if extra.get("address"):
|
||||
return True
|
||||
import hermes_cli.gateway as gateway_mod
|
||||
return bool((gateway_mod.get_env_value("EMAIL_ADDRESS") or "").strip())
|
||||
|
||||
|
||||
def _build_adapter(config):
|
||||
"""Factory wrapper that constructs EmailAdapter from a PlatformConfig."""
|
||||
return EmailAdapter(config)
|
||||
|
||||
|
||||
def register(ctx) -> None:
|
||||
"""Plugin entry point — called by the Hermes plugin system."""
|
||||
ctx.register_platform(
|
||||
name="email",
|
||||
label="Email",
|
||||
adapter_factory=_build_adapter,
|
||||
check_fn=check_email_requirements,
|
||||
is_connected=_is_connected,
|
||||
required_env=["EMAIL_ADDRESS", "EMAIL_PASSWORD", "EMAIL_SMTP_HOST"],
|
||||
install_hint="Email uses the Python stdlib (smtplib/imaplib) — no extra deps",
|
||||
allowed_users_env="EMAIL_ALLOWED_USERS",
|
||||
allow_all_env="EMAIL_ALLOW_ALL_USERS",
|
||||
cron_deliver_env_var="EMAIL_HOME_ADDRESS",
|
||||
standalone_sender_fn=_standalone_send,
|
||||
max_message_length=50_000,
|
||||
pii_safe=True,
|
||||
emoji="📧",
|
||||
allow_update_command=True,
|
||||
)
|
||||
39
plugins/platforms/email/plugin.yaml
Normal file
39
plugins/platforms/email/plugin.yaml
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
name: email-platform
|
||||
label: Email
|
||||
kind: platform
|
||||
version: 1.0.0
|
||||
description: >
|
||||
Email gateway adapter for Hermes Agent. Polls an IMAP mailbox for inbound
|
||||
messages and replies over SMTP, relaying email threads to and from the
|
||||
Hermes agent.
|
||||
author: NousResearch
|
||||
requires_env:
|
||||
- name: EMAIL_ADDRESS
|
||||
description: "Email account address"
|
||||
prompt: "Email address"
|
||||
password: false
|
||||
- name: EMAIL_PASSWORD
|
||||
description: "Email account password / app password"
|
||||
prompt: "Email password"
|
||||
password: true
|
||||
- name: EMAIL_SMTP_HOST
|
||||
description: "SMTP host (e.g. smtp.gmail.com)"
|
||||
prompt: "SMTP host"
|
||||
password: false
|
||||
optional_env:
|
||||
- name: EMAIL_SMTP_PORT
|
||||
description: "SMTP port (default 587)"
|
||||
prompt: "SMTP port"
|
||||
password: false
|
||||
- name: EMAIL_IMAP_HOST
|
||||
description: "IMAP host for inbound polling (e.g. imap.gmail.com)"
|
||||
prompt: "IMAP host"
|
||||
password: false
|
||||
- name: EMAIL_ALLOWED_USERS
|
||||
description: "Comma-separated email addresses allowed to talk to the bot"
|
||||
prompt: "Allowed users (comma-separated)"
|
||||
password: false
|
||||
- name: EMAIL_HOME_ADDRESS
|
||||
description: "Default address for cron / notification delivery"
|
||||
prompt: "Home address"
|
||||
password: false
|
||||
3
plugins/platforms/feishu/__init__.py
Normal file
3
plugins/platforms/feishu/__init__.py
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
from .adapter import register
|
||||
|
||||
__all__ = ["register"]
|
||||
|
|
@ -2469,7 +2469,7 @@ class FeishuAdapter(BasePlatformAdapter):
|
|||
logging, and reaction. Scheduling follows the same
|
||||
``run_coroutine_threadsafe`` pattern used by ``_on_message_event``.
|
||||
"""
|
||||
from gateway.platforms.feishu_comment import handle_drive_comment_event
|
||||
from plugins.platforms.feishu.feishu_comment import handle_drive_comment_event
|
||||
|
||||
loop = self._loop
|
||||
if not self._loop_accepts_callbacks(loop):
|
||||
|
|
@ -2482,7 +2482,7 @@ class FeishuAdapter(BasePlatformAdapter):
|
|||
|
||||
def _on_meeting_invited_event(self, data: Any) -> None:
|
||||
"""Handle VC bot meeting invitation notification (vc.bot.meeting_invited_v1)."""
|
||||
from gateway.platforms.feishu_meeting_invite import handle_meeting_invited_event
|
||||
from plugins.platforms.feishu.feishu_meeting_invite import handle_meeting_invited_event
|
||||
|
||||
loop = self._loop
|
||||
if not self._loop_accepts_callbacks(loop):
|
||||
|
|
@ -5211,3 +5211,301 @@ def _qr_register_inner(
|
|||
result["bot_open_id"] = None
|
||||
|
||||
return result
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────
|
||||
# Plugin migration glue (#41112 / #3823)
|
||||
#
|
||||
# Added when the Feishu adapter (+ its feishu_comment / feishu_comment_rules /
|
||||
# feishu_meeting_invite satellites) moved from gateway/platforms/ into this
|
||||
# bundled plugin. Mirrors the Discord (#24356) / Slack migrations: a
|
||||
# register(ctx) entry point plus hook implementations that replace the
|
||||
# per-platform core touchpoints (the Platform.FEISHU elif in gateway/run.py,
|
||||
# the feishu_cfg YAML→env block + _PLATFORM_CONNECTED_CHECKERS entry in
|
||||
# gateway/config.py, the _setup_feishu wizard + _PLATFORMS["feishu"] static
|
||||
# dict in hermes_cli/gateway.py, and the _send_feishu dispatch in
|
||||
# tools/send_message_tool.py).
|
||||
# ──────────────────────────────────────────────────────────────────────────
|
||||
|
||||
_MIGRATION_IMAGE_EXTS = {".jpg", ".jpeg", ".png", ".webp", ".gif"}
|
||||
_MIGRATION_VIDEO_EXTS = {".mp4", ".mov", ".avi", ".mkv", ".3gp"}
|
||||
_MIGRATION_AUDIO_EXTS = {".ogg", ".opus", ".mp3", ".wav", ".m4a", ".flac"}
|
||||
_MIGRATION_VOICE_EXTS = {".ogg", ".opus"}
|
||||
|
||||
|
||||
async def _standalone_send(
|
||||
pconfig,
|
||||
chat_id,
|
||||
message,
|
||||
*,
|
||||
thread_id=None,
|
||||
media_files=None,
|
||||
force_document=False,
|
||||
):
|
||||
"""Out-of-process Feishu/Lark delivery via the adapter's send pipeline.
|
||||
|
||||
Implements the standalone_sender_fn contract so deliver=feishu cron jobs
|
||||
succeed when cron runs separately from the gateway. Builds a transient
|
||||
FeishuAdapter, hydrates its lark client, and sends text + native media
|
||||
(images, video, voice, documents). Replaces the legacy _send_feishu helper.
|
||||
"""
|
||||
if not FEISHU_AVAILABLE:
|
||||
return {"error": "Feishu dependencies not installed. Run: pip install 'hermes-agent[feishu]'"}
|
||||
|
||||
media_files = media_files or []
|
||||
try:
|
||||
adapter = FeishuAdapter(pconfig)
|
||||
domain_name = getattr(adapter, "_domain_name", "feishu")
|
||||
domain = FEISHU_DOMAIN if domain_name != "lark" else LARK_DOMAIN
|
||||
adapter._client = adapter._build_lark_client(domain)
|
||||
metadata = {"thread_id": thread_id} if thread_id else None
|
||||
|
||||
last_result = None
|
||||
if message.strip():
|
||||
last_result = await adapter.send(chat_id, message, metadata=metadata)
|
||||
if not last_result.success:
|
||||
return {"error": f"Feishu send failed: {last_result.error}"}
|
||||
|
||||
for media_path, is_voice in media_files:
|
||||
if not os.path.exists(media_path):
|
||||
return {"error": f"Media file not found: {media_path}"}
|
||||
ext = os.path.splitext(media_path)[1].lower()
|
||||
if ext in _MIGRATION_IMAGE_EXTS:
|
||||
last_result = await adapter.send_image_file(chat_id, media_path, metadata=metadata)
|
||||
elif ext in _MIGRATION_VIDEO_EXTS:
|
||||
last_result = await adapter.send_video(chat_id, media_path, metadata=metadata)
|
||||
elif ext in _MIGRATION_VOICE_EXTS and is_voice:
|
||||
last_result = await adapter.send_voice(chat_id, media_path, metadata=metadata)
|
||||
elif ext in _MIGRATION_AUDIO_EXTS:
|
||||
last_result = await adapter.send_voice(chat_id, media_path, metadata=metadata)
|
||||
else:
|
||||
last_result = await adapter.send_document(chat_id, media_path, metadata=metadata)
|
||||
if not last_result.success:
|
||||
return {"error": f"Feishu media send failed: {last_result.error}"}
|
||||
|
||||
if last_result is None:
|
||||
return {"error": "No deliverable text or media remained after processing MEDIA tags"}
|
||||
return {
|
||||
"success": True,
|
||||
"platform": "feishu",
|
||||
"chat_id": chat_id,
|
||||
"message_id": last_result.message_id,
|
||||
}
|
||||
except Exception as e:
|
||||
return {"error": f"Feishu send failed: {e}"}
|
||||
|
||||
|
||||
def interactive_setup() -> None:
|
||||
"""Interactive setup for Feishu / Lark — scan-to-create or manual creds.
|
||||
|
||||
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.setup import prompt_choice
|
||||
from hermes_cli.cli_output import (
|
||||
prompt,
|
||||
prompt_yes_no,
|
||||
print_header,
|
||||
print_info,
|
||||
print_success,
|
||||
print_warning,
|
||||
print_error,
|
||||
)
|
||||
|
||||
print_header("Feishu / Lark")
|
||||
existing_app_id = get_env_value("FEISHU_APP_ID")
|
||||
existing_secret = get_env_value("FEISHU_APP_SECRET")
|
||||
if existing_app_id and existing_secret:
|
||||
print_success("Feishu / Lark is already configured.")
|
||||
if not prompt_yes_no("Reconfigure Feishu / Lark?", False):
|
||||
return
|
||||
|
||||
method_idx = prompt_choice(
|
||||
"How would you like to set up Feishu / Lark?",
|
||||
[
|
||||
"Scan QR code to create a new bot automatically (recommended)",
|
||||
"Enter existing App ID and App Secret manually",
|
||||
],
|
||||
0,
|
||||
)
|
||||
|
||||
credentials = None
|
||||
used_qr = False
|
||||
|
||||
if method_idx == 0:
|
||||
try:
|
||||
credentials = qr_register()
|
||||
except KeyboardInterrupt:
|
||||
print_warning("Feishu / Lark setup cancelled.")
|
||||
return
|
||||
except Exception as exc:
|
||||
print_warning(f"QR registration failed: {exc}")
|
||||
if credentials:
|
||||
used_qr = True
|
||||
else:
|
||||
print_info("QR setup did not complete. Continuing with manual input.")
|
||||
|
||||
if not credentials:
|
||||
print_info("Go to https://open.feishu.cn/ (or https://open.larksuite.com/ for Lark)")
|
||||
print_info("Create an app, enable the Bot capability, and copy the credentials.")
|
||||
app_id = prompt("App ID", password=False)
|
||||
if not app_id:
|
||||
print_warning("Skipped — Feishu / Lark won't work without an App ID.")
|
||||
return
|
||||
app_secret = prompt("App Secret", password=True)
|
||||
if not app_secret:
|
||||
print_warning("Skipped — Feishu / Lark won't work without an App Secret.")
|
||||
return
|
||||
domain_idx = prompt_choice("Domain", ["feishu (China)", "lark (International)"], 0)
|
||||
domain = "lark" if domain_idx == 1 else "feishu"
|
||||
|
||||
bot_name = None
|
||||
try:
|
||||
bot_info = probe_bot(app_id, app_secret, domain)
|
||||
if bot_info:
|
||||
bot_name = bot_info.get("bot_name")
|
||||
print_success(f"Credentials verified — bot: {bot_name or 'unnamed'}")
|
||||
else:
|
||||
print_warning("Could not verify bot connection. Credentials saved anyway.")
|
||||
except Exception as exc:
|
||||
print_warning(f"Credential verification skipped: {exc}")
|
||||
|
||||
credentials = {
|
||||
"app_id": app_id,
|
||||
"app_secret": app_secret,
|
||||
"domain": domain,
|
||||
"open_id": None,
|
||||
"bot_name": bot_name,
|
||||
}
|
||||
|
||||
app_id = credentials["app_id"]
|
||||
app_secret = credentials["app_secret"]
|
||||
domain = credentials.get("domain", "feishu")
|
||||
open_id = credentials.get("open_id")
|
||||
bot_name = credentials.get("bot_name")
|
||||
|
||||
save_env_value("FEISHU_APP_ID", app_id)
|
||||
save_env_value("FEISHU_APP_SECRET", app_secret)
|
||||
save_env_value("FEISHU_DOMAIN", domain)
|
||||
|
||||
if used_qr:
|
||||
connection_mode = "websocket"
|
||||
else:
|
||||
mode_idx = prompt_choice(
|
||||
"Connection mode",
|
||||
[
|
||||
"WebSocket (recommended — no public URL needed)",
|
||||
"Webhook (requires a reachable HTTP endpoint)",
|
||||
],
|
||||
0,
|
||||
)
|
||||
connection_mode = "webhook" if mode_idx == 1 else "websocket"
|
||||
if connection_mode == "webhook":
|
||||
print_info("Webhook defaults: 127.0.0.1:8765/feishu/webhook")
|
||||
print_info("Override with FEISHU_WEBHOOK_HOST / FEISHU_WEBHOOK_PORT / FEISHU_WEBHOOK_PATH")
|
||||
print_info("For signature verification, set FEISHU_ENCRYPT_KEY and FEISHU_VERIFICATION_TOKEN")
|
||||
save_env_value("FEISHU_CONNECTION_MODE", connection_mode)
|
||||
|
||||
if bot_name:
|
||||
print_success(f"Bot created: {bot_name}")
|
||||
|
||||
access_idx = prompt_choice(
|
||||
"How should direct messages be authorized?",
|
||||
[
|
||||
"Use DM pairing approval (recommended)",
|
||||
"Allow all direct messages",
|
||||
"Only allow listed user IDs",
|
||||
],
|
||||
0,
|
||||
)
|
||||
if access_idx == 0:
|
||||
save_env_value("FEISHU_ALLOW_ALL_USERS", "false")
|
||||
save_env_value("FEISHU_ALLOWED_USERS", "")
|
||||
print_success("DM pairing enabled.")
|
||||
print_info("Unknown users can request access; approve with `hermes pairing approve`.")
|
||||
elif access_idx == 1:
|
||||
save_env_value("FEISHU_ALLOW_ALL_USERS", "true")
|
||||
save_env_value("FEISHU_ALLOWED_USERS", "")
|
||||
print_warning("Open DM access enabled for Feishu / Lark.")
|
||||
else:
|
||||
save_env_value("FEISHU_ALLOW_ALL_USERS", "false")
|
||||
default_allow = open_id or ""
|
||||
allowlist = prompt(
|
||||
"Allowed user IDs (comma-separated)", default_allow, password=False
|
||||
).replace(" ", "")
|
||||
save_env_value("FEISHU_ALLOWED_USERS", allowlist)
|
||||
print_success("Allowlist saved.")
|
||||
|
||||
group_idx = prompt_choice(
|
||||
"How should group chats be handled?",
|
||||
[
|
||||
"Respond only when @mentioned in groups (recommended)",
|
||||
"Disable group chats",
|
||||
],
|
||||
0,
|
||||
)
|
||||
if group_idx == 0:
|
||||
save_env_value("FEISHU_GROUP_POLICY", "open")
|
||||
print_info("Group chats enabled (bot must be @mentioned).")
|
||||
else:
|
||||
save_env_value("FEISHU_GROUP_POLICY", "disabled")
|
||||
print_info("Group chats disabled.")
|
||||
|
||||
home_channel = prompt("Home chat ID (optional, for cron/notifications)", password=False)
|
||||
if home_channel:
|
||||
save_env_value("FEISHU_HOME_CHANNEL", home_channel)
|
||||
print_success(f"Home channel set to {home_channel}")
|
||||
|
||||
print_success("🪽 Feishu / Lark configured!")
|
||||
print_info(f"App ID: {app_id}")
|
||||
print_info(f"Domain: {domain}")
|
||||
if bot_name:
|
||||
print_info(f"Bot: {bot_name}")
|
||||
|
||||
|
||||
def _apply_yaml_config(yaml_cfg: dict, feishu_cfg: dict) -> dict | None:
|
||||
"""Translate config.yaml feishu: keys into FEISHU_* env vars.
|
||||
|
||||
Implements the apply_yaml_config_fn contract (#24849). Mirrors the legacy
|
||||
feishu_cfg block from gateway/config.py::load_gateway_config() (allow_bots).
|
||||
Env vars take precedence over YAML. Returns None — flows through env.
|
||||
"""
|
||||
if "allow_bots" in feishu_cfg and not os.getenv("FEISHU_ALLOW_BOTS"):
|
||||
os.environ["FEISHU_ALLOW_BOTS"] = str(feishu_cfg["allow_bots"]).lower()
|
||||
return None
|
||||
|
||||
|
||||
def _is_connected(config) -> bool:
|
||||
"""Feishu is connected when app_id is configured. Mirrors the legacy
|
||||
_PLATFORM_CONNECTED_CHECKERS[Platform.FEISHU] = lambda cfg: bool(app_id)."""
|
||||
extra = getattr(config, "extra", {}) or {}
|
||||
return bool(extra.get("app_id"))
|
||||
|
||||
|
||||
def _build_adapter(config):
|
||||
"""Factory wrapper that constructs FeishuAdapter from a PlatformConfig."""
|
||||
return FeishuAdapter(config)
|
||||
|
||||
|
||||
def register(ctx) -> None:
|
||||
"""Plugin entry point — called by the Hermes plugin system."""
|
||||
ctx.register_platform(
|
||||
name="feishu",
|
||||
label="Feishu / Lark",
|
||||
adapter_factory=_build_adapter,
|
||||
check_fn=check_feishu_requirements,
|
||||
is_connected=_is_connected,
|
||||
validate_config=_is_connected,
|
||||
required_env=["FEISHU_APP_ID", "FEISHU_APP_SECRET"],
|
||||
install_hint="pip install 'hermes-agent[feishu]'",
|
||||
setup_fn=interactive_setup,
|
||||
apply_yaml_config_fn=_apply_yaml_config,
|
||||
allowed_users_env="FEISHU_ALLOWED_USERS",
|
||||
allow_all_env="FEISHU_ALLOW_ALL_USERS",
|
||||
cron_deliver_env_var="FEISHU_HOME_CHANNEL",
|
||||
standalone_sender_fn=_standalone_send,
|
||||
max_message_length=8000,
|
||||
emoji="🪽",
|
||||
allow_update_command=True,
|
||||
)
|
||||
|
|
@ -1164,7 +1164,7 @@ async def handle_drive_comment_event(
|
|||
)
|
||||
|
||||
# Access control
|
||||
from gateway.platforms.feishu_comment_rules import load_config, resolve_rule, is_user_allowed, has_wiki_keys
|
||||
from plugins.platforms.feishu.feishu_comment_rules import load_config, resolve_rule, is_user_allowed, has_wiki_keys
|
||||
|
||||
comments_cfg = load_config()
|
||||
rule = resolve_rule(comments_cfg, file_type, file_token)
|
||||
44
plugins/platforms/feishu/plugin.yaml
Normal file
44
plugins/platforms/feishu/plugin.yaml
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
name: feishu-platform
|
||||
label: Feishu / Lark
|
||||
kind: platform
|
||||
version: 1.0.0
|
||||
description: >
|
||||
Feishu / Lark gateway adapter for Hermes Agent.
|
||||
Connects to Feishu (China) or Lark (International) via the official
|
||||
lark-oapi SDK over WebSocket or webhook and relays messages between
|
||||
Feishu/Lark chats and the Hermes agent. Supports text, images, video,
|
||||
voice, documents, threads, DM pairing, group @mention gating, drive
|
||||
comment events, and meeting invites.
|
||||
author: NousResearch
|
||||
requires_env:
|
||||
- name: FEISHU_APP_ID
|
||||
description: "Feishu/Lark app ID"
|
||||
prompt: "Feishu App ID"
|
||||
url: "https://open.feishu.cn/"
|
||||
password: false
|
||||
- name: FEISHU_APP_SECRET
|
||||
description: "Feishu/Lark app secret"
|
||||
prompt: "Feishu App Secret"
|
||||
url: "https://open.feishu.cn/"
|
||||
password: true
|
||||
optional_env:
|
||||
- name: FEISHU_DOMAIN
|
||||
description: "Domain: 'feishu' (China) or 'lark' (International)"
|
||||
prompt: "Domain (feishu/lark)"
|
||||
password: false
|
||||
- name: FEISHU_ALLOWED_USERS
|
||||
description: "Comma-separated Feishu user IDs allowed to talk to the bot"
|
||||
prompt: "Allowed users (comma-separated)"
|
||||
password: false
|
||||
- name: FEISHU_ALLOW_ALL_USERS
|
||||
description: "Allow any Feishu user to trigger the bot (dev only)"
|
||||
prompt: "Allow all users? (true/false)"
|
||||
password: false
|
||||
- name: FEISHU_HOME_CHANNEL
|
||||
description: "Default chat ID for cron / notification delivery"
|
||||
prompt: "Home channel ID"
|
||||
password: false
|
||||
- name: FEISHU_HOME_CHANNEL_NAME
|
||||
description: "Display name for the Feishu home channel"
|
||||
prompt: "Home channel display name"
|
||||
password: false
|
||||
3
plugins/platforms/matrix/__init__.py
Normal file
3
plugins/platforms/matrix/__init__.py
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
from .adapter import register
|
||||
|
||||
__all__ = ["register"]
|
||||
|
|
@ -4106,3 +4106,268 @@ class MatrixAdapter(BasePlatformAdapter):
|
|||
result = result.replace(f"\x00PROTECTED{idx}\x00", original)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────
|
||||
# Plugin migration glue (#41112 / #3823)
|
||||
#
|
||||
# Added when the Matrix adapter moved from gateway/platforms/matrix.py into
|
||||
# this bundled plugin. Mirrors the Discord (#24356) / Slack migrations: a
|
||||
# register(ctx) entry point plus hook implementations that replace the
|
||||
# per-platform core touchpoints (the Platform.MATRIX elif in gateway/run.py,
|
||||
# the matrix_cfg YAML→env block in gateway/config.py, the _setup_matrix wizard
|
||||
# + _PLATFORMS["matrix"] static dict in hermes_cli/{setup,gateway}.py, and the
|
||||
# _send_matrix dispatch in tools/send_message_tool.py). Matrix uses the
|
||||
# generic token/api_key connected check, so no is_connected override is needed.
|
||||
# ──────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
async def _standalone_send(
|
||||
pconfig,
|
||||
chat_id,
|
||||
message,
|
||||
*,
|
||||
thread_id=None,
|
||||
media_files=None,
|
||||
force_document=False,
|
||||
):
|
||||
"""Out-of-process Matrix delivery via the Client-Server API.
|
||||
|
||||
Implements the standalone_sender_fn contract so deliver=matrix cron jobs
|
||||
succeed when cron runs separately from the gateway. Converts markdown to
|
||||
HTML for rich rendering, falling back to plain text when the markdown
|
||||
library is absent. Replaces the legacy _send_matrix helper.
|
||||
"""
|
||||
extra = getattr(pconfig, "extra", {}) or {}
|
||||
token = getattr(pconfig, "token", None)
|
||||
try:
|
||||
import aiohttp
|
||||
except ImportError:
|
||||
return {"error": "aiohttp not installed. Run: pip install aiohttp"}
|
||||
try:
|
||||
homeserver = (extra.get("homeserver") or os.getenv("MATRIX_HOMESERVER", "")).rstrip("/")
|
||||
token = token or os.getenv("MATRIX_ACCESS_TOKEN", "")
|
||||
if not homeserver or not token:
|
||||
return {"error": "Matrix not configured (MATRIX_HOMESERVER, MATRIX_ACCESS_TOKEN required)"}
|
||||
txn_id = f"hermes_{int(time.time() * 1000)}_{os.urandom(4).hex()}"
|
||||
from urllib.parse import quote
|
||||
encoded_room = quote(chat_id, safe="")
|
||||
url = f"{homeserver}/_matrix/client/v3/rooms/{encoded_room}/send/m.room.message/{txn_id}"
|
||||
headers = {"Authorization": f"Bearer {token}", "Content-Type": "application/json"}
|
||||
|
||||
payload = {"msgtype": "m.text", "body": message}
|
||||
try:
|
||||
import markdown as _md
|
||||
html = _md.markdown(message, extensions=["fenced_code", "tables"])
|
||||
html = re.sub(r"<h[1-6]>(.*?)</h[1-6]>", r"<strong>\1</strong>", html)
|
||||
payload["format"] = "org.matrix.custom.html"
|
||||
payload["formatted_body"] = html
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
async with aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=30)) as session:
|
||||
async with session.put(url, headers=headers, json=payload) as resp:
|
||||
if resp.status not in {200, 201}:
|
||||
body = await resp.text()
|
||||
return {"error": f"Matrix API error ({resp.status}): {body}"}
|
||||
data = await resp.json()
|
||||
return {"success": True, "platform": "matrix", "chat_id": chat_id, "message_id": data.get("event_id")}
|
||||
except Exception as e:
|
||||
return {"error": f"Matrix send failed: {e}"}
|
||||
|
||||
|
||||
def interactive_setup() -> None:
|
||||
"""Configure Matrix credentials. Replaces hermes_cli/setup.py::_setup_matrix
|
||||
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.cli_output import (
|
||||
prompt,
|
||||
prompt_yes_no,
|
||||
print_header,
|
||||
print_info,
|
||||
print_success,
|
||||
print_warning,
|
||||
)
|
||||
|
||||
print_header("Matrix")
|
||||
existing = get_env_value("MATRIX_ACCESS_TOKEN") or get_env_value("MATRIX_PASSWORD")
|
||||
if existing:
|
||||
print_info("Matrix: already configured")
|
||||
if not prompt_yes_no("Reconfigure Matrix?", False):
|
||||
return
|
||||
|
||||
print_info("Works with any Matrix homeserver (Synapse, Conduit, Dendrite, or matrix.org).")
|
||||
print_info(" 1. Create a bot user on your homeserver, or use your own account")
|
||||
print_info(" 2. Get an access token from Element, or provide user ID + password")
|
||||
homeserver = prompt("Homeserver URL (e.g. https://matrix.example.org)")
|
||||
if homeserver:
|
||||
save_env_value("MATRIX_HOMESERVER", homeserver.rstrip("/"))
|
||||
|
||||
print_info("Auth: provide an access token (recommended), or user ID + password.")
|
||||
token = prompt("Access token (leave empty for password login)", password=True)
|
||||
if token:
|
||||
save_env_value("MATRIX_ACCESS_TOKEN", token)
|
||||
user_id = prompt("User ID (@bot:server — optional, will be auto-detected)")
|
||||
if user_id:
|
||||
save_env_value("MATRIX_USER_ID", user_id)
|
||||
print_success("Matrix access token saved")
|
||||
else:
|
||||
user_id = prompt("User ID (@bot:server)")
|
||||
if user_id:
|
||||
save_env_value("MATRIX_USER_ID", user_id)
|
||||
password = prompt("Password", password=True)
|
||||
if password:
|
||||
save_env_value("MATRIX_PASSWORD", password)
|
||||
print_success("Matrix credentials saved")
|
||||
|
||||
if token or get_env_value("MATRIX_PASSWORD"):
|
||||
want_e2ee = prompt_yes_no("Enable end-to-end encryption (E2EE)?", False)
|
||||
if want_e2ee:
|
||||
save_env_value("MATRIX_ENCRYPTION", "true")
|
||||
print_success("E2EE enabled")
|
||||
|
||||
matrix_pkg = "mautrix[encryption]" if want_e2ee else "mautrix"
|
||||
try:
|
||||
from tools.lazy_deps import ensure as _lazy_ensure, feature_missing
|
||||
_missing_before = feature_missing("platform.matrix")
|
||||
if _missing_before:
|
||||
print_info(f"Installing {matrix_pkg} (+ {len(_missing_before)} runtime deps)...")
|
||||
try:
|
||||
_lazy_ensure("platform.matrix", prompt=False)
|
||||
print_success(f"{matrix_pkg} installed")
|
||||
except Exception as exc:
|
||||
print_warning(
|
||||
"Install failed — run manually: pip install "
|
||||
"'mautrix[encryption]' asyncpg aiosqlite Markdown aiohttp-socks"
|
||||
)
|
||||
print_info(f" Error: {exc}")
|
||||
except ImportError:
|
||||
try:
|
||||
__import__("mautrix")
|
||||
except ImportError:
|
||||
print_info(f"Installing {matrix_pkg}...")
|
||||
import subprocess
|
||||
uv_bin = shutil.which("uv")
|
||||
if uv_bin:
|
||||
result = subprocess.run(
|
||||
[uv_bin, "pip", "install", "--python", _sys.executable, matrix_pkg],
|
||||
capture_output=True, text=True,
|
||||
)
|
||||
else:
|
||||
result = subprocess.run(
|
||||
[_sys.executable, "-m", "pip", "install", matrix_pkg],
|
||||
capture_output=True, text=True,
|
||||
)
|
||||
if result.returncode == 0:
|
||||
print_success(f"{matrix_pkg} installed")
|
||||
else:
|
||||
print_warning(
|
||||
f"Install failed — run manually: pip install "
|
||||
f"'{matrix_pkg}' asyncpg aiosqlite Markdown aiohttp-socks"
|
||||
)
|
||||
|
||||
print_info("🔒 Security: Restrict who can use your bot")
|
||||
print_info(" Matrix user IDs look like @username:server")
|
||||
allowed_users = prompt("Allowed user IDs (comma-separated, leave empty for open access)")
|
||||
if allowed_users:
|
||||
save_env_value("MATRIX_ALLOWED_USERS", allowed_users.replace(" ", ""))
|
||||
print_success("Matrix allowlist configured")
|
||||
else:
|
||||
print_info("⚠️ No allowlist set - anyone who can message the bot can use it!")
|
||||
|
||||
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)")
|
||||
if home_room:
|
||||
save_env_value("MATRIX_HOME_ROOM", home_room)
|
||||
|
||||
|
||||
def _apply_yaml_config(yaml_cfg: dict, matrix_cfg: dict) -> dict | None:
|
||||
"""Translate config.yaml matrix: keys into MATRIX_* env vars.
|
||||
|
||||
Implements the apply_yaml_config_fn contract (#24849). Mirrors the legacy
|
||||
matrix_cfg block from gateway/config.py::load_gateway_config(). Env vars
|
||||
take precedence over YAML. Returns None — everything flows through env.
|
||||
"""
|
||||
if "require_mention" in matrix_cfg and not os.getenv("MATRIX_REQUIRE_MENTION"):
|
||||
os.environ["MATRIX_REQUIRE_MENTION"] = str(matrix_cfg["require_mention"]).lower()
|
||||
au = matrix_cfg.get("allowed_users")
|
||||
if au is not None and not os.getenv("MATRIX_ALLOWED_USERS"):
|
||||
if isinstance(au, list):
|
||||
au = ",".join(str(v) for v in au)
|
||||
os.environ["MATRIX_ALLOWED_USERS"] = str(au)
|
||||
frc = matrix_cfg.get("free_response_rooms")
|
||||
if frc is not None and not os.getenv("MATRIX_FREE_RESPONSE_ROOMS"):
|
||||
if isinstance(frc, list):
|
||||
frc = ",".join(str(v) for v in frc)
|
||||
os.environ["MATRIX_FREE_RESPONSE_ROOMS"] = str(frc)
|
||||
ar = matrix_cfg.get("allowed_rooms")
|
||||
if ar is not None and not os.getenv("MATRIX_ALLOWED_ROOMS"):
|
||||
if isinstance(ar, list):
|
||||
ar = ",".join(str(v) for v in ar)
|
||||
os.environ["MATRIX_ALLOWED_ROOMS"] = str(ar)
|
||||
ignore_patterns = matrix_cfg.get("ignore_user_patterns")
|
||||
if ignore_patterns is not None and not os.getenv("MATRIX_IGNORE_USER_PATTERNS"):
|
||||
if isinstance(ignore_patterns, list):
|
||||
ignore_patterns = ",".join(str(v) for v in ignore_patterns)
|
||||
os.environ["MATRIX_IGNORE_USER_PATTERNS"] = str(ignore_patterns)
|
||||
if "process_notices" in matrix_cfg and not os.getenv("MATRIX_PROCESS_NOTICES"):
|
||||
os.environ["MATRIX_PROCESS_NOTICES"] = str(matrix_cfg["process_notices"]).lower()
|
||||
if "session_scope" in matrix_cfg and not os.getenv("MATRIX_SESSION_SCOPE"):
|
||||
os.environ["MATRIX_SESSION_SCOPE"] = str(matrix_cfg["session_scope"]).lower()
|
||||
if "auto_thread" in matrix_cfg and not os.getenv("MATRIX_AUTO_THREAD"):
|
||||
os.environ["MATRIX_AUTO_THREAD"] = str(matrix_cfg["auto_thread"]).lower()
|
||||
if "dm_mention_threads" in matrix_cfg and not os.getenv("MATRIX_DM_MENTION_THREADS"):
|
||||
os.environ["MATRIX_DM_MENTION_THREADS"] = str(matrix_cfg["dm_mention_threads"]).lower()
|
||||
return None
|
||||
|
||||
|
||||
def _is_connected(config) -> bool:
|
||||
"""Matrix is connected when a homeserver + access token (or password) are
|
||||
configured. Read via hermes_cli.gateway.get_env_value so setup-status
|
||||
callers that patch get_env_value observe the same value, and PlatformConfig
|
||||
extras (homeserver) are honored too. As a built-in, Matrix used the generic
|
||||
token check; as a plugin it needs an explicit is_connected so
|
||||
_platform_status / get_connected_platforms reflect real configuration
|
||||
rather than mere SDK presence. #41112.
|
||||
"""
|
||||
extra = getattr(config, "extra", {}) or {}
|
||||
import hermes_cli.gateway as gateway_mod
|
||||
homeserver = extra.get("homeserver") or gateway_mod.get_env_value("MATRIX_HOMESERVER") or ""
|
||||
token = (
|
||||
getattr(config, "token", None)
|
||||
or gateway_mod.get_env_value("MATRIX_ACCESS_TOKEN")
|
||||
or gateway_mod.get_env_value("MATRIX_PASSWORD")
|
||||
or ""
|
||||
)
|
||||
return bool(str(homeserver).strip() and str(token).strip())
|
||||
|
||||
|
||||
def _build_adapter(config):
|
||||
"""Factory wrapper that constructs MatrixAdapter from a PlatformConfig."""
|
||||
return MatrixAdapter(config)
|
||||
|
||||
|
||||
def register(ctx) -> None:
|
||||
"""Plugin entry point — called by the Hermes plugin system."""
|
||||
ctx.register_platform(
|
||||
name="matrix",
|
||||
label="Matrix",
|
||||
adapter_factory=_build_adapter,
|
||||
check_fn=check_matrix_requirements,
|
||||
is_connected=_is_connected,
|
||||
required_env=["MATRIX_HOMESERVER", "MATRIX_ACCESS_TOKEN"],
|
||||
install_hint="pip install 'mautrix[encryption]'",
|
||||
setup_fn=interactive_setup,
|
||||
apply_yaml_config_fn=_apply_yaml_config,
|
||||
allowed_users_env="MATRIX_ALLOWED_USERS",
|
||||
allow_all_env="MATRIX_ALLOW_ALL_USERS",
|
||||
cron_deliver_env_var="MATRIX_HOME_ROOM",
|
||||
standalone_sender_fn=_standalone_send,
|
||||
max_message_length=4000,
|
||||
emoji="🔐",
|
||||
allow_update_command=True,
|
||||
)
|
||||
41
plugins/platforms/matrix/plugin.yaml
Normal file
41
plugins/platforms/matrix/plugin.yaml
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
name: matrix-platform
|
||||
label: Matrix
|
||||
kind: platform
|
||||
version: 1.0.0
|
||||
description: >
|
||||
Matrix gateway adapter for Hermes Agent.
|
||||
Connects to a Matrix homeserver via mautrix (with optional E2EE) and relays
|
||||
messages between Matrix rooms/DMs and the Hermes agent. Supports threads,
|
||||
HTML/markdown rendering, native media uploads, mention gating, free-response
|
||||
rooms, and per-room allowlists.
|
||||
author: NousResearch
|
||||
requires_env:
|
||||
- name: MATRIX_HOMESERVER
|
||||
description: "Matrix homeserver URL (e.g. https://matrix.org)"
|
||||
prompt: "Matrix homeserver URL"
|
||||
password: false
|
||||
- name: MATRIX_ACCESS_TOKEN
|
||||
description: "Matrix access token (or use MATRIX_PASSWORD for password login)"
|
||||
prompt: "Matrix access token"
|
||||
password: true
|
||||
optional_env:
|
||||
- name: MATRIX_PASSWORD
|
||||
description: "Matrix account password (alternative to MATRIX_ACCESS_TOKEN)"
|
||||
prompt: "Matrix password"
|
||||
password: true
|
||||
- name: MATRIX_ALLOWED_USERS
|
||||
description: "Comma-separated Matrix user IDs allowed to talk to the bot"
|
||||
prompt: "Allowed users (comma-separated)"
|
||||
password: false
|
||||
- name: MATRIX_ALLOW_ALL_USERS
|
||||
description: "Allow any Matrix user to trigger the bot (dev only)"
|
||||
prompt: "Allow all users? (true/false)"
|
||||
password: false
|
||||
- name: MATRIX_HOME_CHANNEL
|
||||
description: "Default room ID for cron / notification delivery"
|
||||
prompt: "Home room ID"
|
||||
password: false
|
||||
- name: MATRIX_HOME_CHANNEL_NAME
|
||||
description: "Display name for the Matrix home room"
|
||||
prompt: "Home room display name"
|
||||
password: false
|
||||
3
plugins/platforms/slack/__init__.py
Normal file
3
plugins/platforms/slack/__init__.py
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
from .adapter import register
|
||||
|
||||
__all__ = ["register"]
|
||||
|
|
@ -3813,3 +3813,299 @@ class SlackAdapter(BasePlatformAdapter):
|
|||
if isinstance(raw, str) and raw.strip():
|
||||
return {part.strip() for part in raw.split(",") if part.strip()}
|
||||
return set()
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────
|
||||
# Plugin migration glue (#41112 / #3823)
|
||||
#
|
||||
# Everything below this line was added when the Slack adapter moved from
|
||||
# ``gateway/platforms/slack.py`` into this bundled plugin. It mirrors the
|
||||
# Discord migration (PR #24356) exactly: a ``register(ctx)`` entry point plus
|
||||
# the hook implementations (``_standalone_send``, ``interactive_setup``,
|
||||
# ``_apply_yaml_config``, ``_is_connected``, ``_build_adapter``) that replace
|
||||
# the per-platform core touchpoints (the ``Platform.SLACK`` elif in
|
||||
# ``gateway/run.py``, the ``slack_cfg`` YAML→env block in ``gateway/config.py``,
|
||||
# the ``_setup_slack`` wizard + ``_PLATFORMS["slack"]`` static dict in
|
||||
# ``hermes_cli/{setup,gateway}.py``, and the ``_send_slack`` dispatch in
|
||||
# ``tools/send_message_tool.py``).
|
||||
# ──────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
async def _standalone_send(
|
||||
pconfig,
|
||||
chat_id,
|
||||
message,
|
||||
*,
|
||||
thread_id=None,
|
||||
media_files=None,
|
||||
force_document=False,
|
||||
):
|
||||
"""Out-of-process Slack delivery via the Web API ``chat.postMessage``.
|
||||
|
||||
Implements the ``standalone_sender_fn`` contract so ``deliver=slack`` cron
|
||||
jobs succeed when the cron process is not co-located with the gateway (the
|
||||
in-process adapter weakref is ``None`` in that case). Replaces the legacy
|
||||
``_send_slack`` helper that used to live in ``tools/send_message_tool.py``.
|
||||
|
||||
mrkdwn formatting is applied exactly as the legacy core path did — via a
|
||||
throwaway ``SlackAdapter`` instance's ``format_message`` — so cron-delivered
|
||||
Slack messages render identically to gateway-delivered ones.
|
||||
"""
|
||||
token = getattr(pconfig, "token", None) or os.getenv("SLACK_BOT_TOKEN", "")
|
||||
if not token:
|
||||
return {"error": "Slack send failed: SLACK_BOT_TOKEN not configured"}
|
||||
|
||||
formatted = message
|
||||
if message:
|
||||
try:
|
||||
_fmt_adapter = SlackAdapter.__new__(SlackAdapter)
|
||||
formatted = _fmt_adapter.format_message(message)
|
||||
except Exception:
|
||||
logger.debug(
|
||||
"Failed to apply Slack mrkdwn formatting in _standalone_send",
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
try:
|
||||
import aiohttp
|
||||
except ImportError:
|
||||
return {"error": "aiohttp not installed. Run: pip install aiohttp"}
|
||||
|
||||
try:
|
||||
from gateway.platforms.base import resolve_proxy_url, proxy_kwargs_for_aiohttp
|
||||
|
||||
_proxy = resolve_proxy_url()
|
||||
_sess_kw, _req_kw = proxy_kwargs_for_aiohttp(_proxy)
|
||||
url = "https://slack.com/api/chat.postMessage"
|
||||
headers = {
|
||||
"Authorization": f"Bearer {token}",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
async with aiohttp.ClientSession(
|
||||
timeout=aiohttp.ClientTimeout(total=30), **_sess_kw
|
||||
) as session:
|
||||
payload = {"channel": chat_id, "text": formatted, "mrkdwn": True}
|
||||
if thread_id:
|
||||
payload["thread_ts"] = thread_id
|
||||
async with session.post(
|
||||
url, headers=headers, json=payload, **_req_kw
|
||||
) as resp:
|
||||
data = await resp.json()
|
||||
if data.get("ok"):
|
||||
return {
|
||||
"success": True,
|
||||
"platform": "slack",
|
||||
"chat_id": chat_id,
|
||||
"message_id": data.get("ts"),
|
||||
}
|
||||
return {"error": f"Slack API error: {data.get('error', 'unknown')}"}
|
||||
except Exception as e:
|
||||
return {"error": f"Slack send failed: {e}"}
|
||||
|
||||
|
||||
def interactive_setup() -> None:
|
||||
"""Guide the user through Slack bot setup.
|
||||
|
||||
Mirrors Discord's ``interactive_setup`` shape: lazy-imports CLI helpers so
|
||||
the plugin's import surface stays small, generates and writes the Slack app
|
||||
manifest, prompts for the bot + app tokens, captures an allowlist, and
|
||||
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.cli_output import (
|
||||
prompt,
|
||||
prompt_yes_no,
|
||||
print_header,
|
||||
print_info,
|
||||
print_success,
|
||||
print_warning,
|
||||
)
|
||||
|
||||
def _write_slack_manifest_and_instruct() -> None:
|
||||
"""Generate the Slack manifest, write it under HERMES_HOME, and print
|
||||
paste-into-Slack instructions. Failures are non-fatal."""
|
||||
try:
|
||||
from hermes_cli.slack_cli import _build_full_manifest
|
||||
from hermes_constants import get_hermes_home
|
||||
import json as _json
|
||||
|
||||
manifest = _build_full_manifest(
|
||||
bot_name="Hermes",
|
||||
bot_description="Your Hermes agent on Slack",
|
||||
)
|
||||
target = Path(get_hermes_home()) / "slack-manifest.json"
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
target.write_text(
|
||||
_json.dumps(manifest, indent=2, ensure_ascii=False) + "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
print_success(f"Slack app manifest written to: {target}")
|
||||
print_info(
|
||||
" Paste it into https://api.slack.com/apps → your app → Features "
|
||||
"→ App Manifest → Edit, then Save. Slack will prompt to "
|
||||
"reinstall if scopes or slash commands changed."
|
||||
)
|
||||
print_info(
|
||||
" Re-run `hermes slack manifest --write` anytime to refresh after "
|
||||
"Hermes adds new commands."
|
||||
)
|
||||
except Exception as e:
|
||||
print_warning(f"Could not write Slack manifest: {e}")
|
||||
|
||||
print_header("Slack")
|
||||
existing = get_env_value("SLACK_BOT_TOKEN")
|
||||
if existing:
|
||||
print_info("Slack: already configured")
|
||||
if not prompt_yes_no("Reconfigure Slack?", False):
|
||||
# Even without reconfiguring, offer to refresh the manifest so
|
||||
# new commands (e.g. /btw, /stop, ...) get registered in Slack.
|
||||
if prompt_yes_no(
|
||||
"Regenerate the Slack app manifest with the latest command "
|
||||
"list? (recommended after `hermes update`)",
|
||||
True,
|
||||
):
|
||||
_write_slack_manifest_and_instruct()
|
||||
return
|
||||
|
||||
print_info("Steps to create a Slack app:")
|
||||
print_info(" 1. Go to https://api.slack.com/apps → Create New App")
|
||||
print_info(" Pick 'From an app manifest' — we'll generate one for you below.")
|
||||
print_info(" 2. Enable Socket Mode: Settings → Socket Mode → Enable")
|
||||
print_info(" • Create an App-Level Token with 'connections:write' scope")
|
||||
print_info(" 3. Install to Workspace: Settings → Install App")
|
||||
print_info(" 4. After installing, invite the bot to channels: /invite @YourBot")
|
||||
print()
|
||||
print_info(" Full guide: https://hermes-agent.nousresearch.com/docs/user-guide/messaging/slack/")
|
||||
print()
|
||||
|
||||
# Generate and write manifest up-front so the user can paste it into
|
||||
# the "Create from manifest" flow instead of clicking through scopes /
|
||||
# events / slash commands one at a time.
|
||||
_write_slack_manifest_and_instruct()
|
||||
|
||||
print()
|
||||
bot_token = prompt("Slack Bot Token (xoxb-...)", password=True)
|
||||
if not bot_token:
|
||||
return
|
||||
save_env_value("SLACK_BOT_TOKEN", bot_token)
|
||||
app_token = prompt("Slack App Token (xapp-...)", password=True)
|
||||
if app_token:
|
||||
save_env_value("SLACK_APP_TOKEN", app_token)
|
||||
print_success("Slack tokens saved")
|
||||
|
||||
print()
|
||||
print_info("🔒 Security: Restrict who can use your bot")
|
||||
print_info(" To find a Member ID: click a user's name → View full profile → ⋮ → Copy member ID")
|
||||
print()
|
||||
allowed_users = prompt(
|
||||
"Allowed user IDs (comma-separated, leave empty to deny everyone except paired users)"
|
||||
)
|
||||
if allowed_users:
|
||||
save_env_value("SLACK_ALLOWED_USERS", allowed_users.replace(" ", ""))
|
||||
print_success("Slack allowlist configured")
|
||||
else:
|
||||
print_warning("⚠️ No Slack allowlist set - unpaired users will be denied by default.")
|
||||
print_info(" Set SLACK_ALLOW_ALL_USERS=true or GATEWAY_ALLOW_ALL_USERS=true only if you intentionally want open workspace access.")
|
||||
|
||||
print()
|
||||
print_info("📬 Home Channel: where Hermes delivers cron job results,")
|
||||
print_info(" cross-platform messages, and notifications.")
|
||||
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)")
|
||||
if home_channel:
|
||||
save_env_value("SLACK_HOME_CHANNEL", home_channel.strip())
|
||||
|
||||
|
||||
def _apply_yaml_config(yaml_cfg: dict, slack_cfg: dict) -> dict | None:
|
||||
"""Translate ``config.yaml`` ``slack:`` keys into ``SLACK_*`` env vars.
|
||||
|
||||
Implements the ``apply_yaml_config_fn`` contract (#24849). Mirrors the
|
||||
legacy ``slack_cfg`` block that used to live in
|
||||
``gateway/config.py::load_gateway_config()`` before this migration.
|
||||
|
||||
The SlackAdapter reads its runtime configuration via ``os.getenv()``
|
||||
throughout the connect / handle code paths, so rather than rewrite those
|
||||
call sites to read from ``PlatformConfig.extra``, this hook keeps the
|
||||
existing env-driven model and owns the YAML→env translation here, next to
|
||||
the adapter that consumes it. Env vars take precedence over YAML — every
|
||||
assignment is guarded by ``not os.getenv(...)`` so explicit env vars
|
||||
survive a config.yaml update. Returns ``None`` because no extras are
|
||||
seeded into ``PlatformConfig.extra`` directly (everything flows through env).
|
||||
"""
|
||||
if "require_mention" in slack_cfg and not os.getenv("SLACK_REQUIRE_MENTION"):
|
||||
os.environ["SLACK_REQUIRE_MENTION"] = str(slack_cfg["require_mention"]).lower()
|
||||
if "strict_mention" in slack_cfg and not os.getenv("SLACK_STRICT_MENTION"):
|
||||
os.environ["SLACK_STRICT_MENTION"] = str(slack_cfg["strict_mention"]).lower()
|
||||
if "allow_bots" in slack_cfg and not os.getenv("SLACK_ALLOW_BOTS"):
|
||||
os.environ["SLACK_ALLOW_BOTS"] = str(slack_cfg["allow_bots"]).lower()
|
||||
frc = slack_cfg.get("free_response_channels")
|
||||
if frc is not None and not os.getenv("SLACK_FREE_RESPONSE_CHANNELS"):
|
||||
if isinstance(frc, list):
|
||||
frc = ",".join(str(v) for v in frc)
|
||||
os.environ["SLACK_FREE_RESPONSE_CHANNELS"] = str(frc)
|
||||
if "reactions" in slack_cfg and not os.getenv("SLACK_REACTIONS"):
|
||||
os.environ["SLACK_REACTIONS"] = str(slack_cfg["reactions"]).lower()
|
||||
ac = slack_cfg.get("allowed_channels")
|
||||
if ac is not None and not os.getenv("SLACK_ALLOWED_CHANNELS"):
|
||||
if isinstance(ac, list):
|
||||
ac = ",".join(str(v) for v in ac)
|
||||
os.environ["SLACK_ALLOWED_CHANNELS"] = str(ac)
|
||||
return None # all settings flow through env; nothing to merge into extras
|
||||
|
||||
|
||||
def _is_connected(config) -> bool:
|
||||
"""Slack is considered connected when SLACK_BOT_TOKEN is set.
|
||||
|
||||
Looks up via ``hermes_cli.gateway.get_env_value`` at call time (not via the
|
||||
plugin's own bound import) so tests that patch ``gateway_mod.get_env_value``
|
||||
can suppress ambient ``SLACK_BOT_TOKEN`` env vars. Matches what the legacy
|
||||
``Platform.SLACK`` connected-check did before this migration.
|
||||
"""
|
||||
import hermes_cli.gateway as gateway_mod
|
||||
|
||||
return bool((gateway_mod.get_env_value("SLACK_BOT_TOKEN") or "").strip())
|
||||
|
||||
|
||||
def _build_adapter(config):
|
||||
"""Factory wrapper that constructs SlackAdapter from a PlatformConfig."""
|
||||
return SlackAdapter(config)
|
||||
|
||||
|
||||
def register(ctx) -> None:
|
||||
"""Plugin entry point — called by the Hermes plugin system."""
|
||||
ctx.register_platform(
|
||||
name="slack",
|
||||
label="Slack",
|
||||
adapter_factory=_build_adapter,
|
||||
check_fn=check_slack_requirements,
|
||||
is_connected=_is_connected,
|
||||
required_env=["SLACK_BOT_TOKEN", "SLACK_APP_TOKEN"],
|
||||
install_hint="pip install 'hermes-agent[slack]'",
|
||||
# Interactive setup wizard — replaces hermes_cli/setup.py::_setup_slack
|
||||
# and the static _PLATFORMS["slack"] dict in hermes_cli/gateway.py.
|
||||
setup_fn=interactive_setup,
|
||||
# YAML→env config bridge — owns the translation of config.yaml slack:
|
||||
# keys (require_mention, strict_mention, allow_bots,
|
||||
# free_response_channels, reactions, allowed_channels) into SLACK_*
|
||||
# env vars that the adapter reads via os.getenv(). Replaces the
|
||||
# hardcoded block in gateway/config.py. Hook contract: #24849.
|
||||
apply_yaml_config_fn=_apply_yaml_config,
|
||||
# Auth env vars for _is_user_authorized() integration
|
||||
allowed_users_env="SLACK_ALLOWED_USERS",
|
||||
allow_all_env="SLACK_ALLOW_ALL_USERS",
|
||||
# Cron home-channel delivery
|
||||
cron_deliver_env_var="SLACK_HOME_CHANNEL",
|
||||
# Out-of-process cron delivery via the Slack Web API. Without this hook,
|
||||
# deliver=slack cron jobs fail with "No live adapter" when cron runs
|
||||
# separately from the gateway. Replaces the _send_slack helper.
|
||||
standalone_sender_fn=_standalone_send,
|
||||
# Slack API allows 40,000 chars; leave margin (matches the legacy
|
||||
# SlackAdapter.MAX_MESSAGE_LENGTH).
|
||||
max_message_length=39000,
|
||||
# Display
|
||||
emoji="💼",
|
||||
allow_update_command=True,
|
||||
)
|
||||
39
plugins/platforms/slack/plugin.yaml
Normal file
39
plugins/platforms/slack/plugin.yaml
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
name: slack-platform
|
||||
label: Slack
|
||||
kind: platform
|
||||
version: 1.0.0
|
||||
description: >
|
||||
Slack gateway adapter for Hermes Agent.
|
||||
Connects to Slack via slack-bolt in Socket Mode and relays messages
|
||||
between Slack channels/DMs and the Hermes agent. Supports slash
|
||||
commands, threads, mrkdwn rendering, approval blocks, free-response
|
||||
channels, mention gating, and channel skill bindings.
|
||||
author: NousResearch
|
||||
requires_env:
|
||||
- name: SLACK_BOT_TOKEN
|
||||
description: "Slack bot token (xoxb-...)"
|
||||
prompt: "Slack Bot Token (xoxb-...)"
|
||||
url: "https://api.slack.com/apps"
|
||||
password: true
|
||||
- name: SLACK_APP_TOKEN
|
||||
description: "Slack app-level token for Socket Mode (xapp-..., scope connections:write)"
|
||||
prompt: "Slack App Token (xapp-...)"
|
||||
url: "https://api.slack.com/apps"
|
||||
password: true
|
||||
optional_env:
|
||||
- name: SLACK_ALLOWED_USERS
|
||||
description: "Comma-separated Slack member IDs allowed to talk to the bot"
|
||||
prompt: "Allowed users (comma-separated)"
|
||||
password: false
|
||||
- name: SLACK_ALLOW_ALL_USERS
|
||||
description: "Allow any Slack user to trigger the bot (dev only)"
|
||||
prompt: "Allow all users? (true/false)"
|
||||
password: false
|
||||
- name: SLACK_HOME_CHANNEL
|
||||
description: "Default channel ID for cron / notification delivery (starts with C)"
|
||||
prompt: "Home channel ID"
|
||||
password: false
|
||||
- name: SLACK_HOME_CHANNEL_NAME
|
||||
description: "Display name for the Slack home channel"
|
||||
prompt: "Home channel display name"
|
||||
password: false
|
||||
3
plugins/platforms/sms/__init__.py
Normal file
3
plugins/platforms/sms/__init__.py
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
from .adapter import register
|
||||
|
||||
__all__ = ["register"]
|
||||
|
|
@ -377,3 +377,117 @@ class SmsAdapter(BasePlatformAdapter):
|
|||
text='<?xml version="1.0" encoding="UTF-8"?><Response></Response>',
|
||||
content_type="application/xml",
|
||||
)
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────
|
||||
# Plugin migration glue (#41112 / #3823)
|
||||
#
|
||||
# Added when the SMS (Twilio) adapter moved from gateway/platforms/sms.py into
|
||||
# this bundled plugin. register() exposes the platform via the registry,
|
||||
# replacing the Platform.SMS elif in gateway/run.py, the
|
||||
# _PLATFORM_CONNECTED_CHECKERS entry in gateway/config.py, the _PLATFORMS["sms"]
|
||||
# static dict in hermes_cli/gateway.py, and the _send_sms dispatch in
|
||||
# tools/send_message_tool.py. TWILIO_* env→PlatformConfig seeding stays in core.
|
||||
# ──────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _strip_markdown_for_sms(message: str) -> str:
|
||||
"""Strip markdown — SMS renders it as literal characters."""
|
||||
message = re.sub(r"\*\*(.+?)\*\*", r"\1", message, flags=re.DOTALL)
|
||||
message = re.sub(r"\*(.+?)\*", r"\1", message, flags=re.DOTALL)
|
||||
message = re.sub(r"__(.+?)__", r"\1", message, flags=re.DOTALL)
|
||||
message = re.sub(r"_(.+?)_", r"\1", message, flags=re.DOTALL)
|
||||
message = re.sub(r"```[a-z]*\n?", "", message)
|
||||
message = re.sub(r"`(.+?)`", r"\1", message)
|
||||
message = re.sub(r"^#{1,6}\s+", "", message, flags=re.MULTILINE)
|
||||
message = re.sub(r"\[([^\]]+)\]\([^\)]+\)", r"\1", message)
|
||||
message = re.sub(r"\n{3,}", "\n\n", message)
|
||||
return message.strip()
|
||||
|
||||
|
||||
async def _standalone_send(
|
||||
pconfig,
|
||||
chat_id,
|
||||
message,
|
||||
*,
|
||||
thread_id=None,
|
||||
media_files=None,
|
||||
force_document=False,
|
||||
):
|
||||
"""Out-of-process SMS delivery via the Twilio REST API. Implements the
|
||||
standalone_sender_fn contract; replaces the legacy _send_sms helper."""
|
||||
auth_token = getattr(pconfig, "api_key", None) or os.getenv("TWILIO_AUTH_TOKEN", "")
|
||||
try:
|
||||
import aiohttp
|
||||
except ImportError:
|
||||
return {"error": "aiohttp not installed. Run: pip install aiohttp"}
|
||||
import base64
|
||||
|
||||
account_sid = os.getenv("TWILIO_ACCOUNT_SID", "")
|
||||
from_number = os.getenv("TWILIO_PHONE_NUMBER", "")
|
||||
if not account_sid or not auth_token or not from_number:
|
||||
return {"error": "SMS not configured (TWILIO_ACCOUNT_SID, TWILIO_AUTH_TOKEN, TWILIO_PHONE_NUMBER required)"}
|
||||
|
||||
message = _strip_markdown_for_sms(message)
|
||||
|
||||
def _redacted_error(text):
|
||||
try:
|
||||
from tools.send_message_tool import _error as _e
|
||||
return _e(text)
|
||||
except Exception:
|
||||
return {"error": text}
|
||||
|
||||
try:
|
||||
from gateway.platforms.base import resolve_proxy_url, proxy_kwargs_for_aiohttp
|
||||
_proxy = resolve_proxy_url()
|
||||
_sess_kw, _req_kw = proxy_kwargs_for_aiohttp(_proxy)
|
||||
creds = f"{account_sid}:{auth_token}"
|
||||
encoded = base64.b64encode(creds.encode("ascii")).decode("ascii")
|
||||
url = f"https://api.twilio.com/2010-04-01/Accounts/{account_sid}/Messages.json"
|
||||
headers = {"Authorization": f"Basic {encoded}"}
|
||||
async with aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=30), **_sess_kw) as session:
|
||||
form_data = aiohttp.FormData()
|
||||
form_data.add_field("From", from_number)
|
||||
form_data.add_field("To", chat_id)
|
||||
form_data.add_field("Body", message)
|
||||
async with session.post(url, data=form_data, headers=headers, **_req_kw) as resp:
|
||||
body = await resp.json()
|
||||
if resp.status >= 400:
|
||||
error_msg = body.get("message", str(body))
|
||||
return _redacted_error(f"Twilio API error ({resp.status}): {error_msg}")
|
||||
return {"success": True, "platform": "sms", "chat_id": chat_id, "message_id": body.get("sid", "")}
|
||||
except Exception as e:
|
||||
return _redacted_error(f"SMS send failed: {e}")
|
||||
|
||||
|
||||
def _is_connected(config) -> bool:
|
||||
"""SMS is connected when Twilio credentials are present. Mirrors the legacy
|
||||
_PLATFORM_CONNECTED_CHECKERS[Platform.SMS] = bool(TWILIO_ACCOUNT_SID)."""
|
||||
import hermes_cli.gateway as gateway_mod
|
||||
return bool((gateway_mod.get_env_value("TWILIO_ACCOUNT_SID") or "").strip())
|
||||
|
||||
|
||||
def _build_adapter(config):
|
||||
"""Factory wrapper that constructs SmsAdapter from a PlatformConfig."""
|
||||
return SmsAdapter(config)
|
||||
|
||||
|
||||
def register(ctx) -> None:
|
||||
"""Plugin entry point — called by the Hermes plugin system."""
|
||||
ctx.register_platform(
|
||||
name="sms",
|
||||
label="SMS (Twilio)",
|
||||
adapter_factory=_build_adapter,
|
||||
check_fn=check_sms_requirements,
|
||||
is_connected=_is_connected,
|
||||
required_env=["TWILIO_ACCOUNT_SID", "TWILIO_AUTH_TOKEN", "TWILIO_PHONE_NUMBER"],
|
||||
install_hint="pip install aiohttp",
|
||||
allowed_users_env="SMS_ALLOWED_USERS",
|
||||
allow_all_env="SMS_ALLOW_ALL_USERS",
|
||||
cron_deliver_env_var="SMS_HOME_CHANNEL",
|
||||
standalone_sender_fn=_standalone_send,
|
||||
max_message_length=MAX_SMS_LENGTH,
|
||||
pii_safe=True,
|
||||
emoji="📱",
|
||||
allow_update_command=True,
|
||||
)
|
||||
32
plugins/platforms/sms/plugin.yaml
Normal file
32
plugins/platforms/sms/plugin.yaml
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
name: sms-platform
|
||||
label: SMS (Twilio)
|
||||
kind: platform
|
||||
version: 1.0.0
|
||||
description: >
|
||||
SMS gateway adapter for Hermes Agent via Twilio. Sends and receives SMS
|
||||
through the Twilio REST API + inbound webhook, relaying texts between phone
|
||||
numbers and the Hermes agent. Markdown is stripped to plain text.
|
||||
author: NousResearch
|
||||
requires_env:
|
||||
- name: TWILIO_ACCOUNT_SID
|
||||
description: "Twilio Account SID"
|
||||
prompt: "Twilio Account SID"
|
||||
url: "https://www.twilio.com/"
|
||||
password: false
|
||||
- name: TWILIO_AUTH_TOKEN
|
||||
description: "Twilio Auth Token"
|
||||
prompt: "Twilio Auth Token"
|
||||
password: true
|
||||
- name: TWILIO_PHONE_NUMBER
|
||||
description: "Twilio phone number (SMS-capable, E.164 format)"
|
||||
prompt: "Twilio phone number"
|
||||
password: false
|
||||
optional_env:
|
||||
- name: SMS_ALLOWED_USERS
|
||||
description: "Comma-separated phone numbers allowed to talk to the bot"
|
||||
prompt: "Allowed users (comma-separated)"
|
||||
password: false
|
||||
- name: SMS_HOME_CHANNEL
|
||||
description: "Default phone number for cron / notification delivery"
|
||||
prompt: "Home number"
|
||||
password: false
|
||||
3
plugins/platforms/telegram/__init__.py
Normal file
3
plugins/platforms/telegram/__init__.py
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
from .adapter import register
|
||||
|
||||
__all__ = ["register"]
|
||||
|
|
@ -82,7 +82,7 @@ from gateway.platforms.base import (
|
|||
SUPPORTED_IMAGE_DOCUMENT_TYPES,
|
||||
utf16_len,
|
||||
)
|
||||
from gateway.platforms.telegram_network import (
|
||||
from plugins.platforms.telegram.telegram_network import (
|
||||
TelegramFallbackTransport,
|
||||
discover_fallback_ips,
|
||||
parse_fallback_ip_env,
|
||||
|
|
@ -6886,3 +6886,232 @@ class TelegramAdapter(BasePlatformAdapter):
|
|||
message_id,
|
||||
"\U0001f44d" if outcome == ProcessingOutcome.SUCCESS else "\U0001f44e",
|
||||
)
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────
|
||||
# Plugin migration glue (#41112 / #3823)
|
||||
#
|
||||
# Added when the Telegram adapter (+ its telegram_network satellite) moved from
|
||||
# gateway/platforms/ into this bundled plugin. Mirrors the Discord (#24356) /
|
||||
# Slack migrations: a register(ctx) entry point plus hook implementations that
|
||||
# replace the per-platform core touchpoints (the Platform.TELEGRAM branch in
|
||||
# gateway/run.py, the telegram_cfg YAML→env/extra block in gateway/config.py,
|
||||
# the _setup_telegram wizard + _PLATFORMS["telegram"] static dict in
|
||||
# hermes_cli/{setup,gateway}.py, and the _send_telegram dispatch in
|
||||
# tools/send_message_tool.py). Telegram uses the generic token connected
|
||||
# check, so no is_connected override is needed.
|
||||
# ──────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _resolve_notifications_mode() -> str:
|
||||
"""Resolve the Telegram notification mode (all/important) from env or
|
||||
config.yaml display.platforms.telegram.notifications, defaulting to
|
||||
'important'. Mirrors the post-construction logic that used to live in
|
||||
gateway/run.py::_create_adapter()."""
|
||||
mode = os.getenv("HERMES_TELEGRAM_NOTIFICATIONS", "")
|
||||
if not mode:
|
||||
try:
|
||||
from gateway.config import load_gateway_config
|
||||
from gateway.run import cfg_get
|
||||
_gw_cfg = load_gateway_config()
|
||||
_raw = cfg_get(_gw_cfg, "display", "platforms", "telegram", "notifications")
|
||||
if _raw not in {None, ""}:
|
||||
mode = str(_raw).strip().lower()
|
||||
except Exception:
|
||||
pass
|
||||
mode = mode or "important"
|
||||
if mode not in {"all", "important"}:
|
||||
logger.warning(
|
||||
"Unknown telegram notifications mode '%s', defaulting to 'important' "
|
||||
"(valid: all, important)", mode,
|
||||
)
|
||||
mode = "important"
|
||||
return mode
|
||||
|
||||
|
||||
def _build_adapter(config):
|
||||
"""Factory wrapper that constructs TelegramAdapter and applies the
|
||||
notification mode (preserving the gateway/run.py post-construction step)."""
|
||||
adapter = TelegramAdapter(config)
|
||||
try:
|
||||
adapter._notifications_mode = _resolve_notifications_mode()
|
||||
except Exception:
|
||||
adapter._notifications_mode = "important"
|
||||
return adapter
|
||||
|
||||
|
||||
def _is_connected(config) -> bool:
|
||||
"""Telegram is connected when a bot token is configured.
|
||||
|
||||
check_telegram_requirements() only verifies the python-telegram-bot SDK is
|
||||
importable, NOT that a token is set — so without this is_connected the
|
||||
registry-driven plugin-enable pass in gateway/config.py would enable
|
||||
Telegram on any machine that merely has the SDK installed. Gate on the
|
||||
token (env or PlatformConfig.token), matching the generic token check
|
||||
Telegram had as a built-in.
|
||||
"""
|
||||
token = getattr(config, "token", None)
|
||||
if not token:
|
||||
import hermes_cli.gateway as gateway_mod
|
||||
token = gateway_mod.get_env_value("TELEGRAM_BOT_TOKEN") or ""
|
||||
return bool(str(token).strip())
|
||||
|
||||
|
||||
async def _standalone_send(
|
||||
pconfig,
|
||||
chat_id,
|
||||
message,
|
||||
*,
|
||||
thread_id=None,
|
||||
media_files=None,
|
||||
force_document=False,
|
||||
):
|
||||
"""Out-of-process Telegram delivery. Delegates to the standalone
|
||||
``_send_telegram`` REST sender in tools/send_message_tool.py (which already
|
||||
handles chunking-agnostic single sends, threads, media, retries, and
|
||||
parse-mode fallback). Implements the standalone_sender_fn contract so
|
||||
deliver=telegram cron jobs succeed when cron runs separately from the
|
||||
gateway."""
|
||||
token = getattr(pconfig, "token", None) or os.getenv("TELEGRAM_BOT_TOKEN", "")
|
||||
disable_link_previews = bool(
|
||||
getattr(pconfig, "extra", {}) and pconfig.extra.get("disable_link_previews")
|
||||
)
|
||||
from tools.send_message_tool import _send_telegram
|
||||
return await _send_telegram(
|
||||
token,
|
||||
chat_id,
|
||||
message,
|
||||
media_files=media_files,
|
||||
thread_id=thread_id,
|
||||
disable_link_previews=disable_link_previews,
|
||||
force_document=force_document,
|
||||
)
|
||||
|
||||
|
||||
def interactive_setup() -> None:
|
||||
"""Configure Telegram bot credentials and allowlist.
|
||||
|
||||
Delegates to the existing CLI setup helpers (managed-bot QR onboarding,
|
||||
token validation, allowlist capture) via lazy import so the full wizard
|
||||
behavior is preserved without duplicating ~150 lines. Replaces the
|
||||
_PLATFORMS["telegram"] static dict dispatch in hermes_cli/gateway.py.
|
||||
"""
|
||||
from hermes_cli import setup as _setup_mod
|
||||
_setup_mod._setup_telegram()
|
||||
|
||||
|
||||
def _apply_yaml_config(yaml_cfg: dict, telegram_cfg: dict) -> dict | None:
|
||||
"""Translate config.yaml telegram: keys into TELEGRAM_* env vars and
|
||||
PlatformConfig.extra entries.
|
||||
|
||||
Implements the apply_yaml_config_fn contract (#24849). Mirrors the legacy
|
||||
telegram_cfg block from gateway/config.py::load_gateway_config(). Env vars
|
||||
take precedence over YAML. Returns a dict of extras to merge into
|
||||
PlatformConfig.extra (disable_topic_auto_rename + runtime flags), or None.
|
||||
"""
|
||||
import json as _json
|
||||
extras: dict = {}
|
||||
|
||||
if "disable_topic_auto_rename" in telegram_cfg:
|
||||
extras.setdefault("disable_topic_auto_rename", telegram_cfg["disable_topic_auto_rename"])
|
||||
|
||||
_effective_rm = telegram_cfg.get("require_mention", yaml_cfg.get("require_mention"))
|
||||
if _effective_rm is not None and not os.getenv("TELEGRAM_REQUIRE_MENTION"):
|
||||
os.environ["TELEGRAM_REQUIRE_MENTION"] = str(_effective_rm).lower()
|
||||
if "mention_patterns" in telegram_cfg and not os.getenv("TELEGRAM_MENTION_PATTERNS"):
|
||||
os.environ["TELEGRAM_MENTION_PATTERNS"] = _json.dumps(telegram_cfg["mention_patterns"])
|
||||
if "exclusive_bot_mentions" in telegram_cfg and not os.getenv("TELEGRAM_EXCLUSIVE_BOT_MENTIONS"):
|
||||
os.environ["TELEGRAM_EXCLUSIVE_BOT_MENTIONS"] = str(telegram_cfg["exclusive_bot_mentions"]).lower()
|
||||
if "guest_mode" in telegram_cfg and not os.getenv("TELEGRAM_GUEST_MODE"):
|
||||
os.environ["TELEGRAM_GUEST_MODE"] = str(telegram_cfg["guest_mode"]).lower()
|
||||
if "observe_unmentioned_group_messages" in telegram_cfg and not os.getenv("TELEGRAM_OBSERVE_UNMENTIONED_GROUP_MESSAGES"):
|
||||
os.environ["TELEGRAM_OBSERVE_UNMENTIONED_GROUP_MESSAGES"] = str(telegram_cfg["observe_unmentioned_group_messages"]).lower()
|
||||
frc = telegram_cfg.get("free_response_chats")
|
||||
if frc is not None and not os.getenv("TELEGRAM_FREE_RESPONSE_CHATS"):
|
||||
if isinstance(frc, list):
|
||||
frc = ",".join(str(v) for v in frc)
|
||||
os.environ["TELEGRAM_FREE_RESPONSE_CHATS"] = str(frc)
|
||||
ac = telegram_cfg.get("allowed_chats")
|
||||
if ac is not None and not os.getenv("TELEGRAM_ALLOWED_CHATS"):
|
||||
if isinstance(ac, list):
|
||||
ac = ",".join(str(v) for v in ac)
|
||||
os.environ["TELEGRAM_ALLOWED_CHATS"] = str(ac)
|
||||
allowed_topics = telegram_cfg.get("allowed_topics")
|
||||
if allowed_topics is not None and not os.getenv("TELEGRAM_ALLOWED_TOPICS"):
|
||||
if isinstance(allowed_topics, list):
|
||||
allowed_topics = ",".join(str(v) for v in allowed_topics)
|
||||
os.environ["TELEGRAM_ALLOWED_TOPICS"] = str(allowed_topics)
|
||||
ignored_threads = telegram_cfg.get("ignored_threads")
|
||||
if ignored_threads is not None and not os.getenv("TELEGRAM_IGNORED_THREADS"):
|
||||
if isinstance(ignored_threads, list):
|
||||
ignored_threads = ",".join(str(v) for v in ignored_threads)
|
||||
os.environ["TELEGRAM_IGNORED_THREADS"] = str(ignored_threads)
|
||||
if "reactions" in telegram_cfg and not os.getenv("TELEGRAM_REACTIONS"):
|
||||
os.environ["TELEGRAM_REACTIONS"] = str(telegram_cfg["reactions"]).lower()
|
||||
if "proxy_url" in telegram_cfg and not os.getenv("TELEGRAM_PROXY"):
|
||||
os.environ["TELEGRAM_PROXY"] = str(telegram_cfg["proxy_url"]).strip()
|
||||
_telegram_extra = telegram_cfg.get("extra") if isinstance(telegram_cfg.get("extra"), dict) else {}
|
||||
_telegram_rtm = (
|
||||
telegram_cfg["reply_to_mode"] if "reply_to_mode" in telegram_cfg
|
||||
else _telegram_extra.get("reply_to_mode")
|
||||
)
|
||||
if _telegram_rtm is not None and not os.getenv("TELEGRAM_REPLY_TO_MODE"):
|
||||
_rtm_str = "off" if _telegram_rtm is False else str(_telegram_rtm).lower()
|
||||
os.environ["TELEGRAM_REPLY_TO_MODE"] = _rtm_str
|
||||
allowed_users = telegram_cfg.get("allow_from")
|
||||
if allowed_users is not None and not os.getenv("TELEGRAM_ALLOWED_USERS"):
|
||||
if isinstance(allowed_users, list):
|
||||
allowed_users = ",".join(str(v) for v in allowed_users)
|
||||
os.environ["TELEGRAM_ALLOWED_USERS"] = str(allowed_users)
|
||||
group_allowed_users = telegram_cfg.get("group_allow_from")
|
||||
if group_allowed_users is not None and not os.getenv("TELEGRAM_GROUP_ALLOWED_USERS"):
|
||||
if isinstance(group_allowed_users, list):
|
||||
group_allowed_users = ",".join(str(v) for v in group_allowed_users)
|
||||
os.environ["TELEGRAM_GROUP_ALLOWED_USERS"] = str(group_allowed_users)
|
||||
group_allowed_chats = telegram_cfg.get("group_allowed_chats")
|
||||
if group_allowed_chats is not None and not os.getenv("TELEGRAM_GROUP_ALLOWED_CHATS"):
|
||||
if isinstance(group_allowed_chats, list):
|
||||
group_allowed_chats = ",".join(str(v) for v in group_allowed_chats)
|
||||
os.environ["TELEGRAM_GROUP_ALLOWED_CHATS"] = str(group_allowed_chats)
|
||||
for _key in ("guest_mode", "disable_link_previews", "observe_unmentioned_group_messages"):
|
||||
if _key in telegram_cfg:
|
||||
extras.setdefault(_key, telegram_cfg[_key])
|
||||
# Pass through telegram-specific extra keys (e.g. base_url proxy override),
|
||||
# but EXCLUDE the generic shared-config keys that _merge_platform_map in
|
||||
# gateway/config.py already merges with correct top-level-over-nested
|
||||
# precedence. The apply_yaml_config_fn dispatch merges our return via
|
||||
# dict.update() (clobber), so re-emitting those generic keys here would
|
||||
# undo that precedence (top-level losing to a nested-fallback block).
|
||||
_GENERIC_MERGE_KEYS = {
|
||||
"reply_prefix", "reply_in_thread", "reply_to_mode",
|
||||
"unauthorized_dm_behavior", "notice_delivery", "require_mention",
|
||||
"channel_skill_bindings", "channel_prompts", "gateway_restart_notification",
|
||||
"allow_from", "allow_admin_from", "dm_policy", "group_policy",
|
||||
}
|
||||
for _k, _v in _telegram_extra.items():
|
||||
if _k not in _GENERIC_MERGE_KEYS:
|
||||
extras.setdefault(_k, _v)
|
||||
|
||||
return extras or None
|
||||
|
||||
|
||||
def register(ctx) -> None:
|
||||
"""Plugin entry point — called by the Hermes plugin system."""
|
||||
ctx.register_platform(
|
||||
name="telegram",
|
||||
label="Telegram",
|
||||
adapter_factory=_build_adapter,
|
||||
check_fn=check_telegram_requirements,
|
||||
is_connected=_is_connected,
|
||||
required_env=["TELEGRAM_BOT_TOKEN"],
|
||||
install_hint="pip install 'hermes-agent[telegram]'",
|
||||
setup_fn=interactive_setup,
|
||||
apply_yaml_config_fn=_apply_yaml_config,
|
||||
allowed_users_env="TELEGRAM_ALLOWED_USERS",
|
||||
allow_all_env="TELEGRAM_ALLOW_ALL_USERS",
|
||||
cron_deliver_env_var="TELEGRAM_HOME_CHANNEL",
|
||||
standalone_sender_fn=_standalone_send,
|
||||
max_message_length=4096,
|
||||
emoji="✈️",
|
||||
allow_update_command=True,
|
||||
)
|
||||
35
plugins/platforms/telegram/plugin.yaml
Normal file
35
plugins/platforms/telegram/plugin.yaml
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
name: telegram-platform
|
||||
label: Telegram
|
||||
kind: platform
|
||||
version: 1.0.0
|
||||
description: >
|
||||
Telegram gateway adapter for Hermes Agent.
|
||||
Connects to Telegram via python-telegram-bot and relays messages between
|
||||
Telegram chats/groups/topics and the Hermes agent. Supports threads/topics,
|
||||
streaming edits, native media, inline keyboards, slash commands, fallback
|
||||
network transport (direct-IP failover), notification modes, mention gating,
|
||||
and per-user/chat allowlists.
|
||||
author: NousResearch
|
||||
requires_env:
|
||||
- name: TELEGRAM_BOT_TOKEN
|
||||
description: "Telegram bot token from @BotFather"
|
||||
prompt: "Telegram bot token"
|
||||
url: "https://t.me/BotFather"
|
||||
password: true
|
||||
optional_env:
|
||||
- name: TELEGRAM_ALLOWED_USERS
|
||||
description: "Comma-separated Telegram user IDs allowed to talk to the bot"
|
||||
prompt: "Allowed users (comma-separated)"
|
||||
password: false
|
||||
- name: TELEGRAM_ALLOW_ALL_USERS
|
||||
description: "Allow any Telegram user to trigger the bot (dev only)"
|
||||
prompt: "Allow all users? (true/false)"
|
||||
password: false
|
||||
- name: TELEGRAM_HOME_CHANNEL
|
||||
description: "Default chat ID for cron / notification delivery"
|
||||
prompt: "Home channel ID"
|
||||
password: false
|
||||
- name: TELEGRAM_HOME_CHANNEL_NAME
|
||||
description: "Display name for the Telegram home channel"
|
||||
prompt: "Home channel display name"
|
||||
password: false
|
||||
3
plugins/platforms/wecom/__init__.py
Normal file
3
plugins/platforms/wecom/__init__.py
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
from .adapter import register
|
||||
|
||||
__all__ = ["register"]
|
||||
|
|
@ -1634,3 +1634,232 @@ def qr_scan_for_bot_info(
|
|||
print() # newline after dots
|
||||
print(f" QR scan timed out ({timeout_seconds // 60} minutes). Please try again.")
|
||||
return None
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────
|
||||
# Plugin migration glue (#41112 / #3823)
|
||||
#
|
||||
# Added when the WeCom adapters (wecom + wecom_callback, sharing the
|
||||
# wecom_crypto satellite) moved from gateway/platforms/ into this bundled
|
||||
# plugin. register() exposes BOTH platforms via the registry, replacing the
|
||||
# Platform.WECOM / Platform.WECOM_CALLBACK elifs in gateway/run.py, the
|
||||
# _PLATFORM_CONNECTED_CHECKERS entries in gateway/config.py, the _setup_wecom
|
||||
# wizard + _PLATFORMS["wecom"] static dict in hermes_cli/gateway.py, and the
|
||||
# _send_wecom dispatch in tools/send_message_tool.py. Env→PlatformConfig
|
||||
# seeding stays in core, same as prior migrations.
|
||||
# ──────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
async def _standalone_send(
|
||||
pconfig,
|
||||
chat_id,
|
||||
message,
|
||||
*,
|
||||
thread_id=None,
|
||||
media_files=None,
|
||||
force_document=False,
|
||||
):
|
||||
"""Out-of-process WeCom delivery via the adapter's WebSocket send pipeline.
|
||||
|
||||
Implements the standalone_sender_fn contract so deliver=wecom cron jobs
|
||||
succeed when cron runs separately from the gateway. Opens an ephemeral
|
||||
WeComAdapter, connects, sends, and disconnects. Replaces the legacy
|
||||
_send_wecom helper.
|
||||
"""
|
||||
if not check_wecom_requirements():
|
||||
return {"error": "WeCom requirements not met. Need aiohttp + WECOM_BOT_ID/SECRET."}
|
||||
try:
|
||||
adapter = WeComAdapter(pconfig)
|
||||
connected = await adapter.connect()
|
||||
if not connected:
|
||||
return {"error": f"WeCom: failed to connect - {getattr(adapter, 'fatal_error_message', None) or 'unknown error'}"}
|
||||
try:
|
||||
result = await adapter.send(chat_id, message)
|
||||
if not result.success:
|
||||
return {"error": f"WeCom send failed: {result.error}"}
|
||||
return {
|
||||
"success": True,
|
||||
"platform": "wecom",
|
||||
"chat_id": chat_id,
|
||||
"message_id": result.message_id,
|
||||
}
|
||||
finally:
|
||||
await adapter.disconnect()
|
||||
except Exception as e:
|
||||
return {"error": f"WeCom send failed: {e}"}
|
||||
|
||||
|
||||
def interactive_setup() -> None:
|
||||
"""Interactive setup for WeCom — QR scan or manual credential input.
|
||||
|
||||
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.setup import prompt_choice
|
||||
from hermes_cli.cli_output import (
|
||||
prompt,
|
||||
prompt_yes_no,
|
||||
print_header,
|
||||
print_info,
|
||||
print_success,
|
||||
print_warning,
|
||||
print_error,
|
||||
)
|
||||
|
||||
print_header("WeCom (Enterprise WeChat)")
|
||||
existing_bot_id = get_env_value("WECOM_BOT_ID")
|
||||
existing_secret = get_env_value("WECOM_SECRET")
|
||||
if existing_bot_id and existing_secret:
|
||||
print_success("WeCom is already configured.")
|
||||
if not prompt_yes_no("Reconfigure WeCom?", False):
|
||||
return
|
||||
|
||||
method_idx = prompt_choice(
|
||||
"How would you like to set up WeCom?",
|
||||
[
|
||||
"Scan QR code to obtain Bot ID and Secret automatically (recommended)",
|
||||
"Enter existing Bot ID and Secret manually",
|
||||
],
|
||||
0,
|
||||
)
|
||||
|
||||
bot_id = None
|
||||
secret = None
|
||||
|
||||
if method_idx == 0:
|
||||
try:
|
||||
credentials = qr_scan_for_bot_info()
|
||||
except KeyboardInterrupt:
|
||||
print_warning("WeCom setup cancelled.")
|
||||
return
|
||||
except Exception as exc:
|
||||
print_warning(f"QR scan failed: {exc}")
|
||||
credentials = None
|
||||
if credentials:
|
||||
bot_id = credentials.get("bot_id", "")
|
||||
secret = credentials.get("secret", "")
|
||||
print_success("✔ QR scan successful! Bot ID and Secret obtained.")
|
||||
if not bot_id or not secret:
|
||||
print_info("QR scan did not complete. Continuing with manual input.")
|
||||
bot_id = None
|
||||
secret = None
|
||||
|
||||
if not bot_id or not secret:
|
||||
print_info("1. Go to WeCom Application → Workspace → Smart Robot -> Create smart robots")
|
||||
print_info("2. Select API Mode")
|
||||
print_info("3. Copy the Bot ID and Secret from the bot's credentials info")
|
||||
print_info("4. The bot connects via WebSocket — no public endpoint needed")
|
||||
bot_id = prompt("Bot ID", password=False)
|
||||
if not bot_id:
|
||||
print_warning("Skipped — WeCom won't work without a Bot ID.")
|
||||
return
|
||||
secret = prompt("Secret", password=True)
|
||||
if not secret:
|
||||
print_warning("Skipped — WeCom won't work without a Secret.")
|
||||
return
|
||||
|
||||
save_env_value("WECOM_BOT_ID", bot_id)
|
||||
save_env_value("WECOM_SECRET", secret)
|
||||
|
||||
print_info("The gateway DENIES all users by default for security.")
|
||||
print_info("Enter user IDs to create an allowlist, or leave empty.")
|
||||
allowed = prompt("Allowed user IDs (comma-separated, or empty)", password=False)
|
||||
if allowed:
|
||||
save_env_value("WECOM_ALLOWED_USERS", allowed.replace(" ", ""))
|
||||
print_success("Saved — only these users can interact with the bot.")
|
||||
else:
|
||||
access_idx = prompt_choice(
|
||||
"How should unauthorized users be handled?",
|
||||
[
|
||||
"Enable open access (anyone can message the bot)",
|
||||
"Use DM pairing (unknown users request access, you approve with 'hermes pairing approve')",
|
||||
"Disable direct messages",
|
||||
"Skip for now (bot will deny all users until configured)",
|
||||
],
|
||||
1,
|
||||
)
|
||||
if access_idx == 0:
|
||||
save_env_value("WECOM_DM_POLICY", "open")
|
||||
save_env_value("GATEWAY_ALLOW_ALL_USERS", "true")
|
||||
print_warning("Open access enabled — anyone can use your bot!")
|
||||
elif access_idx == 1:
|
||||
save_env_value("WECOM_DM_POLICY", "pairing")
|
||||
print_success("DM pairing mode — users will receive a code to request access.")
|
||||
print_info("Approve with: hermes pairing approve <platform> <code>")
|
||||
elif access_idx == 2:
|
||||
save_env_value("WECOM_DM_POLICY", "disabled")
|
||||
print_warning("Direct messages disabled.")
|
||||
else:
|
||||
print_info("Skipped — configure later with 'hermes gateway setup'")
|
||||
|
||||
home = prompt("Home chat ID (optional, for cron/notifications)", password=False)
|
||||
if home:
|
||||
save_env_value("WECOM_HOME_CHANNEL", home)
|
||||
print_success(f"Home channel set to {home}")
|
||||
|
||||
print_success("💬 WeCom configured!")
|
||||
|
||||
|
||||
def _is_connected(config) -> bool:
|
||||
"""WeCom (Smart Robot) is connected when a bot_id is configured. Mirrors the
|
||||
legacy _PLATFORM_CONNECTED_CHECKERS[Platform.WECOM] entry."""
|
||||
extra = getattr(config, "extra", {}) or {}
|
||||
return bool(extra.get("bot_id"))
|
||||
|
||||
|
||||
def _callback_is_connected(config) -> bool:
|
||||
"""WeCom callback mode is connected when corp_id (or a multi-app `apps`
|
||||
block) is configured. Mirrors the legacy
|
||||
_PLATFORM_CONNECTED_CHECKERS[Platform.WECOM_CALLBACK] entry."""
|
||||
extra = getattr(config, "extra", {}) or {}
|
||||
return bool(extra.get("corp_id") or extra.get("apps"))
|
||||
|
||||
|
||||
def _build_adapter(config):
|
||||
"""Factory wrapper that constructs WeComAdapter from a PlatformConfig."""
|
||||
return WeComAdapter(config)
|
||||
|
||||
|
||||
def _build_callback_adapter(config):
|
||||
"""Factory wrapper that constructs WecomCallbackAdapter from a PlatformConfig."""
|
||||
from plugins.platforms.wecom.callback_adapter import WecomCallbackAdapter
|
||||
return WecomCallbackAdapter(config)
|
||||
|
||||
|
||||
def register(ctx) -> None:
|
||||
"""Plugin entry point — registers both WeCom platforms."""
|
||||
ctx.register_platform(
|
||||
name="wecom",
|
||||
label="WeCom (Enterprise WeChat)",
|
||||
adapter_factory=_build_adapter,
|
||||
check_fn=check_wecom_requirements,
|
||||
is_connected=_is_connected,
|
||||
validate_config=_is_connected,
|
||||
required_env=["WECOM_BOT_ID", "WECOM_SECRET"],
|
||||
install_hint="pip install 'hermes-agent[wecom]'",
|
||||
setup_fn=interactive_setup,
|
||||
allowed_users_env="WECOM_ALLOWED_USERS",
|
||||
allow_all_env="WECOM_ALLOW_ALL_USERS",
|
||||
cron_deliver_env_var="WECOM_HOME_CHANNEL",
|
||||
standalone_sender_fn=_standalone_send,
|
||||
max_message_length=4000,
|
||||
emoji="💼",
|
||||
allow_update_command=True,
|
||||
)
|
||||
|
||||
from plugins.platforms.wecom.callback_adapter import check_wecom_callback_requirements
|
||||
ctx.register_platform(
|
||||
name="wecom_callback",
|
||||
label="WeCom Callback (self-built apps)",
|
||||
adapter_factory=_build_callback_adapter,
|
||||
check_fn=check_wecom_callback_requirements,
|
||||
is_connected=_callback_is_connected,
|
||||
validate_config=_callback_is_connected,
|
||||
required_env=["WECOM_CALLBACK_CORP_ID", "WECOM_CALLBACK_CORP_SECRET"],
|
||||
install_hint="pip install 'hermes-agent[wecom]'",
|
||||
allowed_users_env="WECOM_CALLBACK_ALLOWED_USERS",
|
||||
allow_all_env="WECOM_CALLBACK_ALLOW_ALL_USERS",
|
||||
emoji="💼",
|
||||
allow_update_command=True,
|
||||
)
|
||||
|
|
@ -47,7 +47,7 @@ except ImportError:
|
|||
|
||||
from gateway.config import Platform, PlatformConfig
|
||||
from gateway.platforms.base import BasePlatformAdapter, MessageEvent, MessageType, SendResult
|
||||
from gateway.platforms.wecom_crypto import WXBizMsgCrypt, WeComCryptoError
|
||||
from plugins.platforms.wecom.wecom_crypto import WXBizMsgCrypt, WeComCryptoError
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
52
plugins/platforms/wecom/plugin.yaml
Normal file
52
plugins/platforms/wecom/plugin.yaml
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
name: wecom-platform
|
||||
label: WeCom (Enterprise WeChat)
|
||||
kind: platform
|
||||
version: 1.0.0
|
||||
description: >
|
||||
WeCom / Enterprise WeChat gateway adapter for Hermes Agent. Registers two
|
||||
platforms: ``wecom`` (Smart Robot over WebSocket) and ``wecom_callback``
|
||||
(self-built apps over an HTTP callback endpoint with AES message crypto).
|
||||
Relays messages between WeCom chats and the Hermes agent.
|
||||
author: NousResearch
|
||||
requires_env:
|
||||
- name: WECOM_BOT_ID
|
||||
description: "WeCom Smart Robot bot ID"
|
||||
prompt: "WeCom bot ID"
|
||||
password: false
|
||||
- name: WECOM_SECRET
|
||||
description: "WeCom Smart Robot secret"
|
||||
prompt: "WeCom secret"
|
||||
password: true
|
||||
optional_env:
|
||||
- name: WECOM_WEBSOCKET_URL
|
||||
description: "WeCom Smart Robot WebSocket URL"
|
||||
prompt: "WeCom WebSocket URL"
|
||||
password: false
|
||||
- name: WECOM_HOME_CHANNEL
|
||||
description: "Default chat ID for cron / notification delivery"
|
||||
prompt: "Home channel ID"
|
||||
password: false
|
||||
- name: WECOM_ALLOWED_USERS
|
||||
description: "Comma-separated WeCom user IDs allowed to talk to the bot"
|
||||
prompt: "Allowed users (comma-separated)"
|
||||
password: false
|
||||
- name: WECOM_CALLBACK_CORP_ID
|
||||
description: "WeCom callback-mode corp ID (self-built apps)"
|
||||
prompt: "WeCom callback corp ID"
|
||||
password: false
|
||||
- name: WECOM_CALLBACK_CORP_SECRET
|
||||
description: "WeCom callback-mode corp secret"
|
||||
prompt: "WeCom callback corp secret"
|
||||
password: true
|
||||
- name: WECOM_CALLBACK_AGENT_ID
|
||||
description: "WeCom callback-mode agent ID"
|
||||
prompt: "WeCom callback agent ID"
|
||||
password: false
|
||||
- name: WECOM_CALLBACK_TOKEN
|
||||
description: "WeCom callback verification token"
|
||||
prompt: "WeCom callback token"
|
||||
password: true
|
||||
- name: WECOM_CALLBACK_ENCODING_AES_KEY
|
||||
description: "WeCom callback EncodingAESKey for message crypto"
|
||||
prompt: "WeCom callback EncodingAESKey"
|
||||
password: true
|
||||
3
plugins/platforms/whatsapp/__init__.py
Normal file
3
plugins/platforms/whatsapp/__init__.py
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
from .adapter import register
|
||||
|
||||
__all__ = ["register"]
|
||||
|
|
@ -1195,3 +1195,190 @@ class WhatsAppAdapter(WhatsAppBehaviorMixin, BasePlatformAdapter):
|
|||
except Exception as e:
|
||||
print(f"[{self.name}] Error building event: {e}")
|
||||
return None
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────
|
||||
# Plugin migration glue (#41112 / #3823)
|
||||
#
|
||||
# Added when the WhatsApp adapter moved from gateway/platforms/whatsapp.py into
|
||||
# this bundled plugin. Mirrors the Discord (#24356) / Slack migrations: a
|
||||
# register(ctx) entry point plus hook implementations that replace the
|
||||
# per-platform core touchpoints (the Platform.WHATSAPP elif in gateway/run.py,
|
||||
# the whatsapp_cfg YAML→env block + _PLATFORM_CONNECTED_CHECKERS entry in
|
||||
# gateway/config.py, the _setup_whatsapp wizard + _PLATFORMS["whatsapp"] static
|
||||
# dict in hermes_cli/gateway.py, and the _send_whatsapp dispatch in
|
||||
# tools/send_message_tool.py). WhatsApp auth is handled by the Node.js bridge,
|
||||
# so is_connected is always True (matches the legacy checker).
|
||||
# ──────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
async def _standalone_send(
|
||||
pconfig,
|
||||
chat_id,
|
||||
message,
|
||||
*,
|
||||
thread_id=None,
|
||||
media_files=None,
|
||||
force_document=False,
|
||||
):
|
||||
"""Out-of-process WhatsApp delivery via the local bridge HTTP API.
|
||||
|
||||
Implements the standalone_sender_fn contract so deliver=whatsapp cron jobs
|
||||
succeed when cron runs separately from the gateway. Replaces the legacy
|
||||
_send_whatsapp helper.
|
||||
"""
|
||||
extra = getattr(pconfig, "extra", {}) or {}
|
||||
try:
|
||||
import aiohttp
|
||||
except ImportError:
|
||||
return {"error": "aiohttp not installed. Run: pip install aiohttp"}
|
||||
try:
|
||||
bridge_port = extra.get("bridge_port", 3000)
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async with session.post(
|
||||
f"http://localhost:{bridge_port}/send",
|
||||
json={"chatId": chat_id, "message": message},
|
||||
timeout=aiohttp.ClientTimeout(total=30),
|
||||
) as resp:
|
||||
if resp.status == 200:
|
||||
data = await resp.json()
|
||||
return {
|
||||
"success": True,
|
||||
"platform": "whatsapp",
|
||||
"chat_id": chat_id,
|
||||
"message_id": data.get("messageId"),
|
||||
}
|
||||
body = await resp.text()
|
||||
return {"error": f"WhatsApp bridge error ({resp.status}): {body}"}
|
||||
except Exception as e:
|
||||
return {"error": f"WhatsApp send failed: {e}"}
|
||||
|
||||
|
||||
def interactive_setup() -> None:
|
||||
"""Guide the user through WhatsApp setup.
|
||||
|
||||
Replaces the central _setup_whatsapp in hermes_cli/gateway.py and the
|
||||
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.cli_output import (
|
||||
prompt,
|
||||
prompt_yes_no,
|
||||
print_header,
|
||||
print_info,
|
||||
print_success,
|
||||
)
|
||||
|
||||
print_header("WhatsApp")
|
||||
print_info("WhatsApp uses a local Node.js bridge (WhatsApp Web client).")
|
||||
print_info("Start the bridge separately; the gateway connects to it over HTTP.")
|
||||
existing = get_env_value("WHATSAPP_ENABLED")
|
||||
if existing and existing.lower() in {"true", "1", "yes"}:
|
||||
print_info("WhatsApp: already enabled")
|
||||
if not prompt_yes_no("Reconfigure WhatsApp?", False):
|
||||
return
|
||||
|
||||
if prompt_yes_no("Enable WhatsApp?", True):
|
||||
save_env_value("WHATSAPP_ENABLED", "true")
|
||||
print_success("WhatsApp enabled")
|
||||
else:
|
||||
save_env_value("WHATSAPP_ENABLED", "false")
|
||||
print_info("WhatsApp left disabled")
|
||||
return
|
||||
|
||||
allowed_users = prompt(
|
||||
"Allowed user IDs (comma-separated, leave empty for no allowlist)"
|
||||
)
|
||||
if allowed_users:
|
||||
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)")
|
||||
if home_channel:
|
||||
save_env_value("WHATSAPP_HOME_CHANNEL", home_channel.strip())
|
||||
|
||||
|
||||
def _apply_yaml_config(yaml_cfg: dict, whatsapp_cfg: dict) -> dict | None:
|
||||
"""Translate config.yaml whatsapp: keys into WHATSAPP_* env vars.
|
||||
|
||||
Implements the apply_yaml_config_fn contract (#24849). Mirrors the legacy
|
||||
whatsapp_cfg block from gateway/config.py::load_gateway_config(). Env vars
|
||||
take precedence over YAML. Returns None — everything flows through env.
|
||||
"""
|
||||
import json as _json
|
||||
if "require_mention" in whatsapp_cfg and not os.getenv("WHATSAPP_REQUIRE_MENTION"):
|
||||
os.environ["WHATSAPP_REQUIRE_MENTION"] = str(whatsapp_cfg["require_mention"]).lower()
|
||||
if "mention_patterns" in whatsapp_cfg and not os.getenv("WHATSAPP_MENTION_PATTERNS"):
|
||||
os.environ["WHATSAPP_MENTION_PATTERNS"] = _json.dumps(whatsapp_cfg["mention_patterns"])
|
||||
frc = whatsapp_cfg.get("free_response_chats")
|
||||
if frc is not None and not os.getenv("WHATSAPP_FREE_RESPONSE_CHATS"):
|
||||
if isinstance(frc, list):
|
||||
frc = ",".join(str(v) for v in frc)
|
||||
os.environ["WHATSAPP_FREE_RESPONSE_CHATS"] = str(frc)
|
||||
if "dm_policy" in whatsapp_cfg and not os.getenv("WHATSAPP_DM_POLICY"):
|
||||
os.environ["WHATSAPP_DM_POLICY"] = str(whatsapp_cfg["dm_policy"]).lower()
|
||||
af = whatsapp_cfg.get("allow_from")
|
||||
if af is not None and not os.getenv("WHATSAPP_ALLOWED_USERS"):
|
||||
if isinstance(af, list):
|
||||
af = ",".join(str(v) for v in af)
|
||||
os.environ["WHATSAPP_ALLOWED_USERS"] = str(af)
|
||||
if "group_policy" in whatsapp_cfg and not os.getenv("WHATSAPP_GROUP_POLICY"):
|
||||
os.environ["WHATSAPP_GROUP_POLICY"] = str(whatsapp_cfg["group_policy"]).lower()
|
||||
gaf = whatsapp_cfg.get("group_allow_from")
|
||||
if gaf is not None and not os.getenv("WHATSAPP_GROUP_ALLOWED_USERS"):
|
||||
if isinstance(gaf, list):
|
||||
gaf = ",".join(str(v) for v in gaf)
|
||||
os.environ["WHATSAPP_GROUP_ALLOWED_USERS"] = str(gaf)
|
||||
return None
|
||||
|
||||
|
||||
def _is_connected(config) -> bool:
|
||||
"""WhatsApp is considered connected when the user has explicitly enabled it
|
||||
via ``WHATSAPP_ENABLED`` (or the YAML-bridged equivalent on the config).
|
||||
|
||||
Auth itself is handled by the external Node.js bridge — we can't verify the
|
||||
bridge token here — so the opt-in flag is the connection signal. The legacy
|
||||
built-in path keyed off ``WHATSAPP_ENABLED`` in both the connected-platforms
|
||||
check and the setup-status display; returning an unconditional True here
|
||||
would make WhatsApp always show as "configured" in ``hermes setup`` even
|
||||
when the user never enabled it. #41112.
|
||||
"""
|
||||
extra = getattr(config, "extra", {}) or {}
|
||||
if config is not None and getattr(config, "enabled", False) and extra:
|
||||
# An explicitly-enabled PlatformConfig with seeded extras (e.g. from
|
||||
# YAML) counts as configured.
|
||||
return True
|
||||
# Read via hermes_cli.gateway.get_env_value (not os.getenv) so setup-status
|
||||
# callers that patch get_env_value — and the gateway connected-platforms
|
||||
# check — observe the same value. Matches the discord/slack plugin pattern.
|
||||
import hermes_cli.gateway as gateway_mod
|
||||
val = (gateway_mod.get_env_value("WHATSAPP_ENABLED") or "").strip().lower()
|
||||
return val in {"true", "1", "yes"}
|
||||
|
||||
|
||||
def _build_adapter(config):
|
||||
"""Factory wrapper that constructs WhatsAppAdapter from a PlatformConfig."""
|
||||
return WhatsAppAdapter(config)
|
||||
|
||||
|
||||
def register(ctx) -> None:
|
||||
"""Plugin entry point — called by the Hermes plugin system."""
|
||||
ctx.register_platform(
|
||||
name="whatsapp",
|
||||
label="WhatsApp",
|
||||
adapter_factory=_build_adapter,
|
||||
check_fn=check_whatsapp_requirements,
|
||||
is_connected=_is_connected,
|
||||
required_env=["WHATSAPP_ENABLED"],
|
||||
install_hint="WhatsApp requires a Node.js bridge — see the WhatsApp messaging docs",
|
||||
setup_fn=interactive_setup,
|
||||
apply_yaml_config_fn=_apply_yaml_config,
|
||||
allowed_users_env="WHATSAPP_ALLOWED_USERS",
|
||||
allow_all_env="WHATSAPP_ALLOW_ALL_USERS",
|
||||
cron_deliver_env_var="WHATSAPP_HOME_CHANNEL",
|
||||
standalone_sender_fn=_standalone_send,
|
||||
max_message_length=4096,
|
||||
emoji="💬",
|
||||
allow_update_command=True,
|
||||
)
|
||||
33
plugins/platforms/whatsapp/plugin.yaml
Normal file
33
plugins/platforms/whatsapp/plugin.yaml
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
name: whatsapp-platform
|
||||
label: WhatsApp
|
||||
kind: platform
|
||||
version: 1.0.0
|
||||
description: >
|
||||
WhatsApp gateway adapter for Hermes Agent.
|
||||
Connects to WhatsApp via a local Node.js bridge (WhatsApp Web client) over
|
||||
an HTTP API and relays messages between WhatsApp chats and the Hermes agent.
|
||||
Supports DM/group policies, mention gating, free-response chats, and
|
||||
per-user allowlists.
|
||||
author: NousResearch
|
||||
requires_env:
|
||||
- name: WHATSAPP_ENABLED
|
||||
description: "Enable the WhatsApp adapter (requires the Node.js bridge running)"
|
||||
prompt: "Enable WhatsApp? (true/false)"
|
||||
password: false
|
||||
optional_env:
|
||||
- name: WHATSAPP_ALLOWED_USERS
|
||||
description: "Comma-separated WhatsApp user IDs allowed to talk to the bot"
|
||||
prompt: "Allowed users (comma-separated)"
|
||||
password: false
|
||||
- name: WHATSAPP_ALLOW_ALL_USERS
|
||||
description: "Allow any WhatsApp user to trigger the bot (dev only)"
|
||||
prompt: "Allow all users? (true/false)"
|
||||
password: false
|
||||
- name: WHATSAPP_HOME_CHANNEL
|
||||
description: "Default chat ID for cron / notification delivery"
|
||||
prompt: "Home channel ID"
|
||||
password: false
|
||||
- name: WHATSAPP_HOME_CHANNEL_NAME
|
||||
description: "Display name for the WhatsApp home channel"
|
||||
prompt: "Home channel display name"
|
||||
password: false
|
||||
|
|
@ -118,12 +118,12 @@ _ensure_discord_mock()
|
|||
_ensure_slack_mock()
|
||||
|
||||
import discord # noqa: E402 — mocked above
|
||||
from gateway.platforms.telegram import TelegramAdapter # noqa: E402
|
||||
from plugins.platforms.telegram.adapter import TelegramAdapter # noqa: E402
|
||||
from plugins.platforms.discord.adapter import DiscordAdapter # noqa: E402
|
||||
|
||||
import gateway.platforms.slack as _slack_mod # noqa: E402
|
||||
import plugins.platforms.slack.adapter as _slack_mod # noqa: E402
|
||||
_slack_mod.SLACK_AVAILABLE = True
|
||||
from gateway.platforms.slack import SlackAdapter # noqa: E402
|
||||
from plugins.platforms.slack.adapter import SlackAdapter # noqa: E402
|
||||
|
||||
|
||||
# Platform-generic factories
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
The ``_ensure_telegram_mock`` helper guarantees that a minimal mock of
|
||||
the ``telegram`` package is registered in :data:`sys.modules` **before**
|
||||
any test file triggers ``from gateway.platforms.telegram import ...``.
|
||||
any test file triggers ``from plugins.platforms.telegram.adapter import ...``.
|
||||
|
||||
Without this, ``pytest-xdist`` workers that happen to collect
|
||||
``test_telegram_caption_merge.py`` (bare top-level import, no per-file
|
||||
|
|
|
|||
|
|
@ -35,7 +35,7 @@ def make_adapter_skeleton(
|
|||
require_mention: bool = True,
|
||||
group_policy: str = "allowlist",
|
||||
) -> Any:
|
||||
from gateway.platforms.feishu import FeishuAdapter
|
||||
from plugins.platforms.feishu.adapter import FeishuAdapter
|
||||
|
||||
adapter = object.__new__(FeishuAdapter)
|
||||
adapter._bot_open_id = bot_open_id
|
||||
|
|
|
|||
|
|
@ -24,7 +24,7 @@ from gateway.config import Platform, PlatformConfig
|
|||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _make_telegram_adapter(*, allowed_chats=None, require_mention=None, guest_mode=False):
|
||||
from gateway.platforms.telegram import TelegramAdapter
|
||||
from plugins.platforms.telegram.adapter import TelegramAdapter
|
||||
|
||||
extra = {"guest_mode": guest_mode}
|
||||
if allowed_chats is not None:
|
||||
|
|
@ -162,8 +162,8 @@ class TestTelegramAllowedChats:
|
|||
|
||||
def _make_dingtalk_adapter(*, allowed_chats=None, require_mention=None):
|
||||
# Import lazily — DingTalk SDK may not be installed.
|
||||
pytest.importorskip("gateway.platforms.dingtalk", reason="DingTalk adapter not importable")
|
||||
from gateway.platforms.dingtalk import DingTalkAdapter
|
||||
pytest.importorskip("plugins.platforms.dingtalk.adapter", reason="DingTalk adapter not importable")
|
||||
from plugins.platforms.dingtalk.adapter import DingTalkAdapter
|
||||
|
||||
extra = {}
|
||||
if allowed_chats is not None:
|
||||
|
|
|
|||
|
|
@ -667,7 +667,7 @@ class TestLoadGatewayConfig:
|
|||
|
||||
telegram = config.platforms[Platform.TELEGRAM]
|
||||
assert telegram.extra.get("allow_from") == ["777888999"], (
|
||||
"allow_from configured under gateway.platforms.telegram must be "
|
||||
"allow_from configured under plugins.platforms.telegram.adapter must be "
|
||||
"bridged into PlatformConfig.extra by the shared-key loop"
|
||||
)
|
||||
assert telegram.extra.get("require_mention") is False
|
||||
|
|
|
|||
|
|
@ -108,11 +108,11 @@ def test_base_adapter_defaults_to_not_owning_access_policy():
|
|||
@pytest.mark.parametrize(
|
||||
"module_path, class_name",
|
||||
[
|
||||
("gateway.platforms.wecom", "WeComAdapter"),
|
||||
("plugins.platforms.wecom.adapter", "WeComAdapter"),
|
||||
("gateway.platforms.weixin", "WeixinAdapter"),
|
||||
("gateway.platforms.yuanbao", "YuanbaoAdapter"),
|
||||
("gateway.platforms.qqbot.adapter", "QQAdapter"),
|
||||
("gateway.platforms.whatsapp", "WhatsAppAdapter"),
|
||||
("plugins.platforms.whatsapp.adapter", "WhatsAppAdapter"),
|
||||
],
|
||||
)
|
||||
def test_own_policy_adapters_declare_the_flag(module_path, class_name):
|
||||
|
|
|
|||
|
|
@ -39,7 +39,7 @@ class _FakeChatbotMessage(SimpleNamespace):
|
|||
@pytest.fixture(autouse=True)
|
||||
def _fake_dingtalk_optional_sdks(monkeypatch):
|
||||
"""Keep DingTalk adapter tests hermetic when optional SDKs are absent."""
|
||||
from gateway.platforms import dingtalk as dt
|
||||
import plugins.platforms.dingtalk.adapter as dt
|
||||
|
||||
card_models = SimpleNamespace(**{
|
||||
name: _FakeDingTalkModel
|
||||
|
|
@ -94,29 +94,29 @@ class TestDingTalkRequirements:
|
|||
with patch.dict("sys.modules", {"dingtalk_stream": None}), \
|
||||
patch("tools.lazy_deps.ensure", side_effect=ImportError("dingtalk_stream unavailable")):
|
||||
monkeypatch.setattr(
|
||||
"gateway.platforms.dingtalk.DINGTALK_STREAM_AVAILABLE", False
|
||||
"plugins.platforms.dingtalk.adapter.DINGTALK_STREAM_AVAILABLE", False
|
||||
)
|
||||
from gateway.platforms.dingtalk import check_dingtalk_requirements
|
||||
from plugins.platforms.dingtalk.adapter import check_dingtalk_requirements
|
||||
assert check_dingtalk_requirements() is False
|
||||
|
||||
def test_returns_false_when_env_vars_missing(self, monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
"gateway.platforms.dingtalk.DINGTALK_STREAM_AVAILABLE", True
|
||||
"plugins.platforms.dingtalk.adapter.DINGTALK_STREAM_AVAILABLE", True
|
||||
)
|
||||
monkeypatch.setattr("gateway.platforms.dingtalk.HTTPX_AVAILABLE", True)
|
||||
monkeypatch.setattr("plugins.platforms.dingtalk.adapter.HTTPX_AVAILABLE", True)
|
||||
monkeypatch.delenv("DINGTALK_CLIENT_ID", raising=False)
|
||||
monkeypatch.delenv("DINGTALK_CLIENT_SECRET", raising=False)
|
||||
from gateway.platforms.dingtalk import check_dingtalk_requirements
|
||||
from plugins.platforms.dingtalk.adapter import check_dingtalk_requirements
|
||||
assert check_dingtalk_requirements() is False
|
||||
|
||||
def test_returns_true_when_all_available(self, monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
"gateway.platforms.dingtalk.DINGTALK_STREAM_AVAILABLE", True
|
||||
"plugins.platforms.dingtalk.adapter.DINGTALK_STREAM_AVAILABLE", True
|
||||
)
|
||||
monkeypatch.setattr("gateway.platforms.dingtalk.HTTPX_AVAILABLE", True)
|
||||
monkeypatch.setattr("plugins.platforms.dingtalk.adapter.HTTPX_AVAILABLE", True)
|
||||
monkeypatch.setenv("DINGTALK_CLIENT_ID", "test-id")
|
||||
monkeypatch.setenv("DINGTALK_CLIENT_SECRET", "test-secret")
|
||||
from gateway.platforms.dingtalk import check_dingtalk_requirements
|
||||
from plugins.platforms.dingtalk.adapter import check_dingtalk_requirements
|
||||
assert check_dingtalk_requirements() is True
|
||||
|
||||
|
||||
|
|
@ -128,7 +128,7 @@ class TestDingTalkRequirements:
|
|||
class TestDingTalkAdapterInit:
|
||||
|
||||
def test_reads_config_from_extra(self):
|
||||
from gateway.platforms.dingtalk import DingTalkAdapter
|
||||
from plugins.platforms.dingtalk.adapter import DingTalkAdapter
|
||||
config = PlatformConfig(
|
||||
enabled=True,
|
||||
extra={"client_id": "cfg-id", "client_secret": "cfg-secret"},
|
||||
|
|
@ -141,7 +141,7 @@ class TestDingTalkAdapterInit:
|
|||
def test_falls_back_to_env_vars(self, monkeypatch):
|
||||
monkeypatch.setenv("DINGTALK_CLIENT_ID", "env-id")
|
||||
monkeypatch.setenv("DINGTALK_CLIENT_SECRET", "env-secret")
|
||||
from gateway.platforms.dingtalk import DingTalkAdapter
|
||||
from plugins.platforms.dingtalk.adapter import DingTalkAdapter
|
||||
config = PlatformConfig(enabled=True)
|
||||
adapter = DingTalkAdapter(config)
|
||||
assert adapter._client_id == "env-id"
|
||||
|
|
@ -156,28 +156,28 @@ class TestDingTalkAdapterInit:
|
|||
class TestExtractText:
|
||||
|
||||
def test_extracts_dict_text(self):
|
||||
from gateway.platforms.dingtalk import DingTalkAdapter
|
||||
from plugins.platforms.dingtalk.adapter import DingTalkAdapter
|
||||
msg = MagicMock()
|
||||
msg.text = {"content": " hello world "}
|
||||
msg.rich_text = None
|
||||
assert DingTalkAdapter._extract_text(msg) == "hello world"
|
||||
|
||||
def test_extracts_string_text(self):
|
||||
from gateway.platforms.dingtalk import DingTalkAdapter
|
||||
from plugins.platforms.dingtalk.adapter import DingTalkAdapter
|
||||
msg = MagicMock()
|
||||
msg.text = "plain text"
|
||||
msg.rich_text = None
|
||||
assert DingTalkAdapter._extract_text(msg) == "plain text"
|
||||
|
||||
def test_falls_back_to_rich_text(self):
|
||||
from gateway.platforms.dingtalk import DingTalkAdapter
|
||||
from plugins.platforms.dingtalk.adapter import DingTalkAdapter
|
||||
msg = MagicMock()
|
||||
msg.text = ""
|
||||
msg.rich_text = [{"text": "part1"}, {"text": "part2"}, {"image": "url"}]
|
||||
assert DingTalkAdapter._extract_text(msg) == "part1 part2"
|
||||
|
||||
def test_returns_empty_for_no_content(self):
|
||||
from gateway.platforms.dingtalk import DingTalkAdapter
|
||||
from plugins.platforms.dingtalk.adapter import DingTalkAdapter
|
||||
msg = MagicMock()
|
||||
msg.text = ""
|
||||
msg.rich_text = None
|
||||
|
|
@ -192,24 +192,24 @@ class TestExtractText:
|
|||
class TestDeduplication:
|
||||
|
||||
def test_first_message_not_duplicate(self):
|
||||
from gateway.platforms.dingtalk import DingTalkAdapter
|
||||
from plugins.platforms.dingtalk.adapter import DingTalkAdapter
|
||||
adapter = DingTalkAdapter(PlatformConfig(enabled=True))
|
||||
assert adapter._dedup.is_duplicate("msg-1") is False
|
||||
|
||||
def test_second_same_message_is_duplicate(self):
|
||||
from gateway.platforms.dingtalk import DingTalkAdapter
|
||||
from plugins.platforms.dingtalk.adapter import DingTalkAdapter
|
||||
adapter = DingTalkAdapter(PlatformConfig(enabled=True))
|
||||
adapter._dedup.is_duplicate("msg-1")
|
||||
assert adapter._dedup.is_duplicate("msg-1") is True
|
||||
|
||||
def test_different_messages_not_duplicate(self):
|
||||
from gateway.platforms.dingtalk import DingTalkAdapter
|
||||
from plugins.platforms.dingtalk.adapter import DingTalkAdapter
|
||||
adapter = DingTalkAdapter(PlatformConfig(enabled=True))
|
||||
adapter._dedup.is_duplicate("msg-1")
|
||||
assert adapter._dedup.is_duplicate("msg-2") is False
|
||||
|
||||
def test_cache_cleanup_on_overflow(self):
|
||||
from gateway.platforms.dingtalk import DingTalkAdapter
|
||||
from plugins.platforms.dingtalk.adapter import DingTalkAdapter
|
||||
adapter = DingTalkAdapter(PlatformConfig(enabled=True))
|
||||
max_size = adapter._dedup._max_size
|
||||
# Fill beyond max
|
||||
|
|
@ -228,7 +228,7 @@ class TestSend:
|
|||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_posts_to_webhook(self):
|
||||
from gateway.platforms.dingtalk import DingTalkAdapter
|
||||
from plugins.platforms.dingtalk.adapter import DingTalkAdapter
|
||||
adapter = DingTalkAdapter(PlatformConfig(enabled=True))
|
||||
|
||||
mock_response = MagicMock()
|
||||
|
|
@ -254,7 +254,7 @@ class TestSend:
|
|||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_fails_without_webhook(self):
|
||||
from gateway.platforms.dingtalk import DingTalkAdapter
|
||||
from plugins.platforms.dingtalk.adapter import DingTalkAdapter
|
||||
adapter = DingTalkAdapter(PlatformConfig(enabled=True))
|
||||
adapter._http_client = AsyncMock()
|
||||
|
||||
|
|
@ -264,7 +264,7 @@ class TestSend:
|
|||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_uses_cached_webhook(self):
|
||||
from gateway.platforms.dingtalk import DingTalkAdapter
|
||||
from plugins.platforms.dingtalk.adapter import DingTalkAdapter
|
||||
adapter = DingTalkAdapter(PlatformConfig(enabled=True))
|
||||
|
||||
mock_response = MagicMock()
|
||||
|
|
@ -280,7 +280,7 @@ class TestSend:
|
|||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_handles_http_error(self):
|
||||
from gateway.platforms.dingtalk import DingTalkAdapter
|
||||
from plugins.platforms.dingtalk.adapter import DingTalkAdapter
|
||||
adapter = DingTalkAdapter(PlatformConfig(enabled=True))
|
||||
|
||||
mock_response = MagicMock()
|
||||
|
|
@ -299,7 +299,7 @@ class TestSend:
|
|||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_image_renders_markdown_image(self):
|
||||
from gateway.platforms.dingtalk import DingTalkAdapter
|
||||
from plugins.platforms.dingtalk.adapter import DingTalkAdapter
|
||||
adapter = DingTalkAdapter(PlatformConfig(enabled=True))
|
||||
|
||||
mock_response = MagicMock()
|
||||
|
|
@ -324,7 +324,7 @@ class TestSend:
|
|||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_image_file_returns_explicit_unsupported_error(self):
|
||||
from gateway.platforms.dingtalk import DingTalkAdapter
|
||||
from plugins.platforms.dingtalk.adapter import DingTalkAdapter
|
||||
adapter = DingTalkAdapter(PlatformConfig(enabled=True))
|
||||
|
||||
result = await adapter.send_image_file("chat-123", "/tmp/demo.png")
|
||||
|
|
@ -334,7 +334,7 @@ class TestSend:
|
|||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_document_returns_explicit_unsupported_error(self):
|
||||
from gateway.platforms.dingtalk import DingTalkAdapter
|
||||
from plugins.platforms.dingtalk.adapter import DingTalkAdapter
|
||||
adapter = DingTalkAdapter(PlatformConfig(enabled=True))
|
||||
|
||||
result = await adapter.send_document("chat-123", "/tmp/demo.pdf")
|
||||
|
|
@ -352,7 +352,7 @@ class TestConnect:
|
|||
|
||||
@pytest.mark.asyncio
|
||||
async def test_disconnect_closes_session_websocket(self):
|
||||
from gateway.platforms.dingtalk import DingTalkAdapter
|
||||
from plugins.platforms.dingtalk.adapter import DingTalkAdapter
|
||||
|
||||
adapter = DingTalkAdapter(PlatformConfig(enabled=True))
|
||||
websocket = AsyncMock()
|
||||
|
|
@ -376,16 +376,16 @@ class TestConnect:
|
|||
@pytest.mark.asyncio
|
||||
async def test_connect_fails_without_sdk(self, monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
"gateway.platforms.dingtalk.DINGTALK_STREAM_AVAILABLE", False
|
||||
"plugins.platforms.dingtalk.adapter.DINGTALK_STREAM_AVAILABLE", False
|
||||
)
|
||||
from gateway.platforms.dingtalk import DingTalkAdapter
|
||||
from plugins.platforms.dingtalk.adapter import DingTalkAdapter
|
||||
adapter = DingTalkAdapter(PlatformConfig(enabled=True))
|
||||
result = await adapter.connect()
|
||||
assert result is False
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_connect_fails_without_credentials(self):
|
||||
from gateway.platforms.dingtalk import DingTalkAdapter
|
||||
from plugins.platforms.dingtalk.adapter import DingTalkAdapter
|
||||
adapter = DingTalkAdapter(PlatformConfig(enabled=True))
|
||||
adapter._client_id = ""
|
||||
adapter._client_secret = ""
|
||||
|
|
@ -394,7 +394,7 @@ class TestConnect:
|
|||
|
||||
@pytest.mark.asyncio
|
||||
async def test_disconnect_cleans_up(self):
|
||||
from gateway.platforms.dingtalk import DingTalkAdapter
|
||||
from plugins.platforms.dingtalk.adapter import DingTalkAdapter
|
||||
adapter = DingTalkAdapter(PlatformConfig(enabled=True))
|
||||
adapter._session_webhooks["a"] = "http://x"
|
||||
adapter._dedup._seen["b"] = 1.0
|
||||
|
|
@ -410,7 +410,7 @@ class TestConnect:
|
|||
async def test_disconnect_finalizes_open_streaming_cards(self):
|
||||
"""Streaming cards must be finalized before HTTP client closes."""
|
||||
from unittest.mock import AsyncMock, patch
|
||||
from gateway.platforms.dingtalk import DingTalkAdapter
|
||||
from plugins.platforms.dingtalk.adapter import DingTalkAdapter
|
||||
adapter = DingTalkAdapter(PlatformConfig(enabled=True))
|
||||
adapter._http_client = AsyncMock()
|
||||
adapter._stream_task = None
|
||||
|
|
@ -456,29 +456,29 @@ class TestWebhookDomainAllowlist:
|
|||
"""
|
||||
|
||||
def test_api_domain_accepted(self):
|
||||
from gateway.platforms.dingtalk import _DINGTALK_WEBHOOK_RE
|
||||
from plugins.platforms.dingtalk.adapter import _DINGTALK_WEBHOOK_RE
|
||||
assert _DINGTALK_WEBHOOK_RE.match(
|
||||
"https://api.dingtalk.com/robot/send?access_token=x"
|
||||
)
|
||||
|
||||
def test_oapi_domain_accepted(self):
|
||||
from gateway.platforms.dingtalk import _DINGTALK_WEBHOOK_RE
|
||||
from plugins.platforms.dingtalk.adapter import _DINGTALK_WEBHOOK_RE
|
||||
assert _DINGTALK_WEBHOOK_RE.match(
|
||||
"https://oapi.dingtalk.com/robot/send?access_token=x"
|
||||
)
|
||||
|
||||
def test_http_rejected(self):
|
||||
from gateway.platforms.dingtalk import _DINGTALK_WEBHOOK_RE
|
||||
from plugins.platforms.dingtalk.adapter import _DINGTALK_WEBHOOK_RE
|
||||
assert not _DINGTALK_WEBHOOK_RE.match("http://api.dingtalk.com/robot/send")
|
||||
|
||||
def test_suffix_attack_rejected(self):
|
||||
from gateway.platforms.dingtalk import _DINGTALK_WEBHOOK_RE
|
||||
from plugins.platforms.dingtalk.adapter import _DINGTALK_WEBHOOK_RE
|
||||
assert not _DINGTALK_WEBHOOK_RE.match(
|
||||
"https://api.dingtalk.com.evil.example/"
|
||||
)
|
||||
|
||||
def test_unsanctioned_subdomain_rejected(self):
|
||||
from gateway.platforms.dingtalk import _DINGTALK_WEBHOOK_RE
|
||||
from plugins.platforms.dingtalk.adapter import _DINGTALK_WEBHOOK_RE
|
||||
# Only api.* and oapi.* are allowed — e.g. eapi.dingtalk.com must not slip through
|
||||
assert not _DINGTALK_WEBHOOK_RE.match("https://eapi.dingtalk.com/robot/send")
|
||||
|
||||
|
|
@ -487,7 +487,7 @@ class TestHandlerProcessIsAsync:
|
|||
"""dingtalk-stream >= 0.20 requires ``process`` to be a coroutine."""
|
||||
|
||||
def test_process_is_coroutine_function(self):
|
||||
from gateway.platforms.dingtalk import _IncomingHandler
|
||||
from plugins.platforms.dingtalk.adapter import _IncomingHandler
|
||||
assert asyncio.iscoroutinefunction(_IncomingHandler.process)
|
||||
|
||||
|
||||
|
|
@ -501,7 +501,7 @@ class TestExtractText:
|
|||
"""
|
||||
|
||||
def test_text_as_dict_legacy(self):
|
||||
from gateway.platforms.dingtalk import DingTalkAdapter
|
||||
from plugins.platforms.dingtalk.adapter import DingTalkAdapter
|
||||
msg = MagicMock()
|
||||
msg.text = {"content": "hello world"}
|
||||
msg.rich_text_content = None
|
||||
|
|
@ -510,7 +510,7 @@ class TestExtractText:
|
|||
|
||||
def test_text_as_textcontent_object(self):
|
||||
"""SDK >= 0.20 shape: object with ``.content`` attribute."""
|
||||
from gateway.platforms.dingtalk import DingTalkAdapter
|
||||
from plugins.platforms.dingtalk.adapter import DingTalkAdapter
|
||||
|
||||
class FakeTextContent:
|
||||
content = "hello from new sdk"
|
||||
|
|
@ -527,7 +527,7 @@ class TestExtractText:
|
|||
assert "TextContent(" not in result
|
||||
|
||||
def test_text_content_attr_with_empty_string(self):
|
||||
from gateway.platforms.dingtalk import DingTalkAdapter
|
||||
from plugins.platforms.dingtalk.adapter import DingTalkAdapter
|
||||
|
||||
class FakeTextContent:
|
||||
content = ""
|
||||
|
|
@ -540,7 +540,7 @@ class TestExtractText:
|
|||
|
||||
def test_rich_text_content_new_shape(self):
|
||||
"""SDK >= 0.20 exposes rich text as ``message.rich_text_content.rich_text_list``."""
|
||||
from gateway.platforms.dingtalk import DingTalkAdapter
|
||||
from plugins.platforms.dingtalk.adapter import DingTalkAdapter
|
||||
|
||||
class FakeRichText:
|
||||
rich_text_list = [{"text": "hello "}, {"text": "world"}]
|
||||
|
|
@ -554,7 +554,7 @@ class TestExtractText:
|
|||
|
||||
def test_rich_text_legacy_shape(self):
|
||||
"""Legacy ``message.rich_text`` list remains supported."""
|
||||
from gateway.platforms.dingtalk import DingTalkAdapter
|
||||
from plugins.platforms.dingtalk.adapter import DingTalkAdapter
|
||||
msg = MagicMock()
|
||||
msg.text = None
|
||||
msg.rich_text_content = None
|
||||
|
|
@ -563,7 +563,7 @@ class TestExtractText:
|
|||
assert "legacy" in result and "rich" in result
|
||||
|
||||
def test_empty_message(self):
|
||||
from gateway.platforms.dingtalk import DingTalkAdapter
|
||||
from plugins.platforms.dingtalk.adapter import DingTalkAdapter
|
||||
msg = MagicMock()
|
||||
msg.text = None
|
||||
msg.rich_text_content = None
|
||||
|
|
@ -586,7 +586,7 @@ class TestExtractMedia:
|
|||
def test_voice_rich_text_item_classified_as_voice(self):
|
||||
"""Native DingTalk voice notes (type=voice) must enter the auto-STT
|
||||
path via MessageType.VOICE — the gateway skips STT for AUDIO."""
|
||||
from gateway.platforms.dingtalk import DingTalkAdapter
|
||||
from plugins.platforms.dingtalk.adapter import DingTalkAdapter
|
||||
from gateway.platforms.base import MessageType
|
||||
|
||||
msg = self._msg_with_rich_text(
|
||||
|
|
@ -602,7 +602,7 @@ class TestExtractMedia:
|
|||
def test_audio_rich_text_item_stays_audio(self):
|
||||
"""Generic audio uploads (e.g. an mp3 the user attached) must NOT
|
||||
be auto-transcribed — they stay MessageType.AUDIO."""
|
||||
from gateway.platforms.dingtalk import DingTalkAdapter, DINGTALK_TYPE_MAPPING
|
||||
from plugins.platforms.dingtalk.adapter import DingTalkAdapter, DINGTALK_TYPE_MAPPING
|
||||
from gateway.platforms.base import MessageType
|
||||
|
||||
# Simulate a future/non-voice audio rich-text item by extending the
|
||||
|
|
@ -643,7 +643,7 @@ def _make_gating_adapter(monkeypatch, *, extra=None, env=None):
|
|||
monkeypatch.delenv(key, raising=False)
|
||||
for key, value in (env or {}).items():
|
||||
monkeypatch.setenv(key, value)
|
||||
from gateway.platforms.dingtalk import DingTalkAdapter
|
||||
from plugins.platforms.dingtalk.adapter import DingTalkAdapter
|
||||
return DingTalkAdapter(PlatformConfig(enabled=True, extra=extra or {}))
|
||||
|
||||
|
||||
|
|
@ -790,7 +790,7 @@ class TestIncomingHandlerProcess:
|
|||
@pytest.mark.asyncio
|
||||
async def test_process_extracts_session_webhook(self):
|
||||
"""session_webhook must be populated from callback data."""
|
||||
from gateway.platforms.dingtalk import _IncomingHandler, DingTalkAdapter
|
||||
from plugins.platforms.dingtalk.adapter import _IncomingHandler, DingTalkAdapter
|
||||
|
||||
adapter = DingTalkAdapter(PlatformConfig(enabled=True))
|
||||
adapter._on_message = AsyncMock()
|
||||
|
|
@ -823,7 +823,7 @@ class TestIncomingHandlerProcess:
|
|||
"""If ChatbotMessage.from_dict does not map sessionWebhook (e.g. SDK
|
||||
version mismatch), the handler should fall back to extracting it
|
||||
directly from the raw data dict."""
|
||||
from gateway.platforms.dingtalk import _IncomingHandler, DingTalkAdapter
|
||||
from plugins.platforms.dingtalk.adapter import _IncomingHandler, DingTalkAdapter
|
||||
|
||||
adapter = DingTalkAdapter(PlatformConfig(enabled=True))
|
||||
adapter._on_message = AsyncMock()
|
||||
|
|
@ -851,7 +851,7 @@ class TestIncomingHandlerProcess:
|
|||
async def test_process_returns_ack_immediately(self):
|
||||
"""process() must not block on _on_message — it should return
|
||||
the ACK tuple before the message is fully processed."""
|
||||
from gateway.platforms.dingtalk import _IncomingHandler, DingTalkAdapter
|
||||
from plugins.platforms.dingtalk.adapter import _IncomingHandler, DingTalkAdapter
|
||||
|
||||
processing_started = asyncio.Event()
|
||||
processing_gate = asyncio.Event()
|
||||
|
|
@ -895,7 +895,7 @@ class TestExtractTextMentions:
|
|||
Stripping all @handles collateral-damages emails, SSH URLs, and
|
||||
literal references the user wrote.
|
||||
"""
|
||||
from gateway.platforms.dingtalk import DingTalkAdapter
|
||||
from plugins.platforms.dingtalk.adapter import DingTalkAdapter
|
||||
cases = [
|
||||
("@bot hello", "@bot hello"),
|
||||
("contact alice@example.com", "contact alice@example.com"),
|
||||
|
|
@ -928,7 +928,7 @@ class TestMessageContextIsolation:
|
|||
|
||||
def test_contexts_keyed_by_chat_id(self):
|
||||
"""Two concurrent chats must not clobber each other's context."""
|
||||
from gateway.platforms.dingtalk import DingTalkAdapter
|
||||
from plugins.platforms.dingtalk.adapter import DingTalkAdapter
|
||||
adapter = DingTalkAdapter(PlatformConfig(enabled=True))
|
||||
|
||||
msg_a = MagicMock(conversation_id="chat-A", sender_staff_id="user-A")
|
||||
|
|
@ -953,7 +953,7 @@ class TestCardLifecycle:
|
|||
|
||||
@pytest.fixture
|
||||
def adapter_with_card(self):
|
||||
from gateway.platforms.dingtalk import DingTalkAdapter
|
||||
from plugins.platforms.dingtalk.adapter import DingTalkAdapter
|
||||
a = DingTalkAdapter(PlatformConfig(
|
||||
enabled=True,
|
||||
extra={"card_template_id": "tmpl-1"},
|
||||
|
|
@ -1144,7 +1144,7 @@ class TestDingTalkAdapterAICards:
|
|||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_uses_ai_card_if_configured(self, config, mock_stream_client, mock_http_client, mock_message):
|
||||
from gateway.platforms.dingtalk import DingTalkAdapter
|
||||
from plugins.platforms.dingtalk.adapter import DingTalkAdapter
|
||||
|
||||
adapter = DingTalkAdapter(config)
|
||||
adapter._stream_client = mock_stream_client
|
||||
|
|
|
|||
|
|
@ -40,12 +40,12 @@ def _ensure_telegram_mock():
|
|||
sys.modules["telegram.request"] = telegram_mod.request
|
||||
|
||||
# Force reimport so the adapter picks up the mock ChatType.
|
||||
sys.modules.pop("gateway.platforms.telegram", None)
|
||||
sys.modules.pop("plugins.platforms.telegram.adapter", None)
|
||||
|
||||
|
||||
_ensure_telegram_mock()
|
||||
|
||||
from gateway.platforms.telegram import TelegramAdapter # noqa: E402
|
||||
from plugins.platforms.telegram.adapter import TelegramAdapter # noqa: E402
|
||||
|
||||
|
||||
def _make_adapter(dm_topics_config=None, group_topics_config=None):
|
||||
|
|
|
|||
|
|
@ -72,19 +72,19 @@ class TestCheckRequirements(unittest.TestCase):
|
|||
"EMAIL_SMTP_HOST": "smtp.b.com",
|
||||
}, clear=False)
|
||||
def test_requirements_met(self):
|
||||
from gateway.platforms.email import check_email_requirements
|
||||
from plugins.platforms.email.adapter import check_email_requirements
|
||||
self.assertTrue(check_email_requirements())
|
||||
|
||||
@patch.dict(os.environ, {
|
||||
"EMAIL_ADDRESS": "a@b.com",
|
||||
}, clear=True)
|
||||
def test_requirements_not_met(self):
|
||||
from gateway.platforms.email import check_email_requirements
|
||||
from plugins.platforms.email.adapter import check_email_requirements
|
||||
self.assertFalse(check_email_requirements())
|
||||
|
||||
@patch.dict(os.environ, {}, clear=True)
|
||||
def test_requirements_empty_env(self):
|
||||
from gateway.platforms.email import check_email_requirements
|
||||
from plugins.platforms.email.adapter import check_email_requirements
|
||||
self.assertFalse(check_email_requirements())
|
||||
|
||||
|
||||
|
|
@ -92,39 +92,39 @@ class TestHelperFunctions(unittest.TestCase):
|
|||
"""Test email parsing helper functions."""
|
||||
|
||||
def test_decode_header_plain(self):
|
||||
from gateway.platforms.email import _decode_header_value
|
||||
from plugins.platforms.email.adapter import _decode_header_value
|
||||
self.assertEqual(_decode_header_value("Hello World"), "Hello World")
|
||||
|
||||
def test_decode_header_encoded(self):
|
||||
from gateway.platforms.email import _decode_header_value
|
||||
from plugins.platforms.email.adapter import _decode_header_value
|
||||
# RFC 2047 encoded subject
|
||||
encoded = "=?utf-8?B?TWVyaGFiYQ==?=" # "Merhaba" in base64
|
||||
result = _decode_header_value(encoded)
|
||||
self.assertEqual(result, "Merhaba")
|
||||
|
||||
def test_extract_email_address_with_name(self):
|
||||
from gateway.platforms.email import _extract_email_address
|
||||
from plugins.platforms.email.adapter import _extract_email_address
|
||||
self.assertEqual(
|
||||
_extract_email_address("John Doe <john@example.com>"),
|
||||
"john@example.com"
|
||||
)
|
||||
|
||||
def test_extract_email_address_bare(self):
|
||||
from gateway.platforms.email import _extract_email_address
|
||||
from plugins.platforms.email.adapter import _extract_email_address
|
||||
self.assertEqual(
|
||||
_extract_email_address("john@example.com"),
|
||||
"john@example.com"
|
||||
)
|
||||
|
||||
def test_extract_email_address_uppercase(self):
|
||||
from gateway.platforms.email import _extract_email_address
|
||||
from plugins.platforms.email.adapter import _extract_email_address
|
||||
self.assertEqual(
|
||||
_extract_email_address("John@Example.COM"),
|
||||
"john@example.com"
|
||||
)
|
||||
|
||||
def test_strip_html_basic(self):
|
||||
from gateway.platforms.email import _strip_html
|
||||
from plugins.platforms.email.adapter import _strip_html
|
||||
html = "<p>Hello <b>world</b></p>"
|
||||
result = _strip_html(html)
|
||||
self.assertIn("Hello", result)
|
||||
|
|
@ -133,14 +133,14 @@ class TestHelperFunctions(unittest.TestCase):
|
|||
self.assertNotIn("<b>", result)
|
||||
|
||||
def test_strip_html_br_tags(self):
|
||||
from gateway.platforms.email import _strip_html
|
||||
from plugins.platforms.email.adapter import _strip_html
|
||||
html = "Line 1<br>Line 2<br/>Line 3"
|
||||
result = _strip_html(html)
|
||||
self.assertIn("Line 1", result)
|
||||
self.assertIn("Line 2", result)
|
||||
|
||||
def test_strip_html_entities(self):
|
||||
from gateway.platforms.email import _strip_html
|
||||
from plugins.platforms.email.adapter import _strip_html
|
||||
html = "a & b < c > d"
|
||||
result = _strip_html(html)
|
||||
self.assertIn("a & b", result)
|
||||
|
|
@ -150,20 +150,20 @@ class TestExtractTextBody(unittest.TestCase):
|
|||
"""Test email body extraction from different message formats."""
|
||||
|
||||
def test_plain_text_body(self):
|
||||
from gateway.platforms.email import _extract_text_body
|
||||
from plugins.platforms.email.adapter import _extract_text_body
|
||||
msg = MIMEText("Hello, this is a test.", "plain", "utf-8")
|
||||
result = _extract_text_body(msg)
|
||||
self.assertEqual(result, "Hello, this is a test.")
|
||||
|
||||
def test_html_body_fallback(self):
|
||||
from gateway.platforms.email import _extract_text_body
|
||||
from plugins.platforms.email.adapter import _extract_text_body
|
||||
msg = MIMEText("<p>Hello from HTML</p>", "html", "utf-8")
|
||||
result = _extract_text_body(msg)
|
||||
self.assertIn("Hello from HTML", result)
|
||||
self.assertNotIn("<p>", result)
|
||||
|
||||
def test_multipart_prefers_plain(self):
|
||||
from gateway.platforms.email import _extract_text_body
|
||||
from plugins.platforms.email.adapter import _extract_text_body
|
||||
msg = MIMEMultipart("alternative")
|
||||
msg.attach(MIMEText("<p>HTML version</p>", "html", "utf-8"))
|
||||
msg.attach(MIMEText("Plain version", "plain", "utf-8"))
|
||||
|
|
@ -171,14 +171,14 @@ class TestExtractTextBody(unittest.TestCase):
|
|||
self.assertEqual(result, "Plain version")
|
||||
|
||||
def test_multipart_html_only(self):
|
||||
from gateway.platforms.email import _extract_text_body
|
||||
from plugins.platforms.email.adapter import _extract_text_body
|
||||
msg = MIMEMultipart("alternative")
|
||||
msg.attach(MIMEText("<p>Only HTML</p>", "html", "utf-8"))
|
||||
result = _extract_text_body(msg)
|
||||
self.assertIn("Only HTML", result)
|
||||
|
||||
def test_empty_body(self):
|
||||
from gateway.platforms.email import _extract_text_body
|
||||
from plugins.platforms.email.adapter import _extract_text_body
|
||||
msg = MIMEText("", "plain", "utf-8")
|
||||
result = _extract_text_body(msg)
|
||||
self.assertEqual(result, "")
|
||||
|
|
@ -188,14 +188,14 @@ class TestExtractAttachments(unittest.TestCase):
|
|||
"""Test attachment extraction and caching."""
|
||||
|
||||
def test_no_attachments(self):
|
||||
from gateway.platforms.email import _extract_attachments
|
||||
from plugins.platforms.email.adapter import _extract_attachments
|
||||
msg = MIMEText("No attachments here.", "plain", "utf-8")
|
||||
result = _extract_attachments(msg)
|
||||
self.assertEqual(result, [])
|
||||
|
||||
@patch("gateway.platforms.email.cache_document_from_bytes")
|
||||
@patch("plugins.platforms.email.adapter.cache_document_from_bytes")
|
||||
def test_document_attachment(self, mock_cache):
|
||||
from gateway.platforms.email import _extract_attachments
|
||||
from plugins.platforms.email.adapter import _extract_attachments
|
||||
mock_cache.return_value = "/tmp/cached_doc.pdf"
|
||||
|
||||
msg = MIMEMultipart()
|
||||
|
|
@ -213,9 +213,9 @@ class TestExtractAttachments(unittest.TestCase):
|
|||
self.assertEqual(result[0]["filename"], "report.pdf")
|
||||
mock_cache.assert_called_once()
|
||||
|
||||
@patch("gateway.platforms.email.cache_image_from_bytes")
|
||||
@patch("plugins.platforms.email.adapter.cache_image_from_bytes")
|
||||
def test_image_attachment(self, mock_cache):
|
||||
from gateway.platforms.email import _extract_attachments
|
||||
from plugins.platforms.email.adapter import _extract_attachments
|
||||
mock_cache.return_value = "/tmp/cached_img.jpg"
|
||||
|
||||
msg = MIMEMultipart()
|
||||
|
|
@ -248,7 +248,7 @@ class TestDispatchMessage(unittest.TestCase):
|
|||
"EMAIL_SMTP_PORT": "587",
|
||||
"EMAIL_POLL_INTERVAL": "15",
|
||||
}):
|
||||
from gateway.platforms.email import EmailAdapter
|
||||
from plugins.platforms.email.adapter import EmailAdapter
|
||||
adapter = EmailAdapter(PlatformConfig(enabled=True))
|
||||
return adapter
|
||||
|
||||
|
|
@ -582,7 +582,7 @@ class TestThreadContext(unittest.TestCase):
|
|||
"EMAIL_IMAP_HOST": "imap.test.com",
|
||||
"EMAIL_SMTP_HOST": "smtp.test.com",
|
||||
}):
|
||||
from gateway.platforms.email import EmailAdapter
|
||||
from plugins.platforms.email.adapter import EmailAdapter
|
||||
adapter = EmailAdapter(PlatformConfig(enabled=True))
|
||||
return adapter
|
||||
|
||||
|
|
@ -679,7 +679,7 @@ class TestSendMethods(unittest.TestCase):
|
|||
"EMAIL_IMAP_HOST": "imap.test.com",
|
||||
"EMAIL_SMTP_HOST": "smtp.test.com",
|
||||
}):
|
||||
from gateway.platforms.email import EmailAdapter
|
||||
from plugins.platforms.email.adapter import EmailAdapter
|
||||
adapter = EmailAdapter(PlatformConfig(enabled=True))
|
||||
return adapter
|
||||
|
||||
|
|
@ -798,7 +798,7 @@ class TestConnectDisconnect(unittest.TestCase):
|
|||
"EMAIL_IMAP_HOST": "imap.test.com",
|
||||
"EMAIL_SMTP_HOST": "smtp.test.com",
|
||||
}):
|
||||
from gateway.platforms.email import EmailAdapter
|
||||
from plugins.platforms.email.adapter import EmailAdapter
|
||||
adapter = EmailAdapter(PlatformConfig(enabled=True))
|
||||
return adapter
|
||||
|
||||
|
|
@ -876,7 +876,7 @@ class TestFetchNewMessages(unittest.TestCase):
|
|||
"EMAIL_IMAP_HOST": "imap.test.com",
|
||||
"EMAIL_SMTP_HOST": "smtp.test.com",
|
||||
}):
|
||||
from gateway.platforms.email import EmailAdapter
|
||||
from plugins.platforms.email.adapter import EmailAdapter
|
||||
adapter = EmailAdapter(PlatformConfig(enabled=True))
|
||||
return adapter
|
||||
|
||||
|
|
@ -970,7 +970,7 @@ class TestPollLoop(unittest.TestCase):
|
|||
"EMAIL_SMTP_HOST": "smtp.test.com",
|
||||
"EMAIL_POLL_INTERVAL": "1",
|
||||
}):
|
||||
from gateway.platforms.email import EmailAdapter
|
||||
from plugins.platforms.email.adapter import EmailAdapter
|
||||
adapter = EmailAdapter(PlatformConfig(enabled=True))
|
||||
return adapter
|
||||
|
||||
|
|
@ -1021,7 +1021,10 @@ class TestSendEmailStandalone(unittest.TestCase):
|
|||
"""_send_email should use verified STARTTLS when sending."""
|
||||
import asyncio
|
||||
import ssl
|
||||
from tools.send_message_tool import _send_email
|
||||
from plugins.platforms.email.adapter import _standalone_send as _email_send
|
||||
from types import SimpleNamespace
|
||||
async def _send_email(extra, chat_id, message):
|
||||
return await _email_send(SimpleNamespace(token=None, api_key=None, extra=extra or {}), chat_id, message)
|
||||
|
||||
with patch("smtplib.SMTP") as mock_smtp:
|
||||
mock_server = MagicMock()
|
||||
|
|
@ -1049,7 +1052,10 @@ class TestSendEmailStandalone(unittest.TestCase):
|
|||
def test_send_email_tool_failure(self):
|
||||
"""SMTP failure should return error dict."""
|
||||
import asyncio
|
||||
from tools.send_message_tool import _send_email
|
||||
from plugins.platforms.email.adapter import _standalone_send as _email_send
|
||||
from types import SimpleNamespace
|
||||
async def _send_email(extra, chat_id, message):
|
||||
return await _email_send(SimpleNamespace(token=None, api_key=None, extra=extra or {}), chat_id, message)
|
||||
|
||||
with patch("smtplib.SMTP", side_effect=Exception("SMTP error")):
|
||||
result = asyncio.run(
|
||||
|
|
@ -1063,7 +1069,10 @@ class TestSendEmailStandalone(unittest.TestCase):
|
|||
def test_send_email_tool_not_configured(self):
|
||||
"""Missing config should return error."""
|
||||
import asyncio
|
||||
from tools.send_message_tool import _send_email
|
||||
from plugins.platforms.email.adapter import _standalone_send as _email_send
|
||||
from types import SimpleNamespace
|
||||
async def _send_email(extra, chat_id, message):
|
||||
return await _email_send(SimpleNamespace(token=None, api_key=None, extra=extra or {}), chat_id, message)
|
||||
|
||||
result = asyncio.run(
|
||||
_send_email({}, "user@test.com", "Hello")
|
||||
|
|
@ -1085,7 +1094,7 @@ class TestSmtpConnectionCleanup(unittest.TestCase):
|
|||
}, clear=False)
|
||||
def _make_adapter(self):
|
||||
from gateway.config import PlatformConfig
|
||||
from gateway.platforms.email import EmailAdapter
|
||||
from plugins.platforms.email.adapter import EmailAdapter
|
||||
return EmailAdapter(PlatformConfig(enabled=True))
|
||||
|
||||
@patch.dict(os.environ, {
|
||||
|
|
@ -1140,7 +1149,7 @@ class TestImapConnectionCleanup(unittest.TestCase):
|
|||
}, clear=False)
|
||||
def _make_adapter(self):
|
||||
from gateway.config import PlatformConfig
|
||||
from gateway.platforms.email import EmailAdapter
|
||||
from plugins.platforms.email.adapter import EmailAdapter
|
||||
return EmailAdapter(PlatformConfig(enabled=True))
|
||||
|
||||
@patch.dict(os.environ, {
|
||||
|
|
@ -1205,7 +1214,7 @@ class TestImapIdExtensionForNetEase(unittest.TestCase):
|
|||
"EMAIL_IMAP_HOST": "imap.163.com",
|
||||
"EMAIL_SMTP_HOST": "smtp.163.com",
|
||||
}):
|
||||
from gateway.platforms.email import EmailAdapter
|
||||
from plugins.platforms.email.adapter import EmailAdapter
|
||||
adapter = EmailAdapter(PlatformConfig(enabled=True))
|
||||
return adapter
|
||||
|
||||
|
|
@ -1256,7 +1265,7 @@ class TestImapIdExtensionForNetEase(unittest.TestCase):
|
|||
|
||||
def test_send_imap_id_swallows_errors_for_non_supporting_servers(self):
|
||||
"""Servers that reject ID must not break the connection."""
|
||||
from gateway.platforms.email import _send_imap_id
|
||||
from plugins.platforms.email.adapter import _send_imap_id
|
||||
|
||||
mock_imap = MagicMock()
|
||||
mock_imap.xatom.side_effect = Exception("BAD command unknown: ID")
|
||||
|
|
@ -1277,7 +1286,7 @@ class TestConnectSmtp(unittest.TestCase):
|
|||
"EMAIL_SMTP_HOST": "smtp.test.com",
|
||||
"EMAIL_SMTP_PORT": port,
|
||||
}):
|
||||
from gateway.platforms.email import EmailAdapter
|
||||
from plugins.platforms.email.adapter import EmailAdapter
|
||||
return EmailAdapter(PlatformConfig(enabled=True))
|
||||
|
||||
def test_port_587_uses_smtp_with_starttls(self):
|
||||
|
|
@ -1314,7 +1323,7 @@ class TestConnectSmtp(unittest.TestCase):
|
|||
def test_ipv6_timeout_falls_back_to_ipv4(self):
|
||||
"""When default connection times out, retry with an IPv4-only SMTP path."""
|
||||
import socket as _socket
|
||||
from gateway.platforms import email as email_mod
|
||||
import plugins.platforms.email.adapter as email_mod
|
||||
|
||||
adapter = self._make_adapter("587")
|
||||
|
||||
|
|
@ -1332,7 +1341,7 @@ class TestConnectSmtp(unittest.TestCase):
|
|||
def test_port_465_ipv6_fallback(self):
|
||||
"""Port 465 IPv6 timeout falls back to IPv4 with SMTP_SSL."""
|
||||
import socket as _socket
|
||||
from gateway.platforms import email as email_mod
|
||||
import plugins.platforms.email.adapter as email_mod
|
||||
|
||||
adapter = self._make_adapter("465")
|
||||
|
||||
|
|
@ -1351,7 +1360,7 @@ class TestConnectSmtp(unittest.TestCase):
|
|||
def test_tls_verification_error_does_not_retry_ipv4(self):
|
||||
"""Certificate failures are security errors, not IPv6 reachability failures."""
|
||||
import ssl as _ssl
|
||||
from gateway.platforms import email as email_mod
|
||||
import plugins.platforms.email.adapter as email_mod
|
||||
|
||||
adapter = self._make_adapter("465")
|
||||
|
||||
|
|
@ -1365,7 +1374,7 @@ class TestConnectSmtp(unittest.TestCase):
|
|||
def test_ipv4_connection_does_not_mutate_global_resolver(self):
|
||||
"""IPv4 fallback must not monkeypatch process-global socket state."""
|
||||
import socket as _socket
|
||||
from gateway.platforms.email import _create_ipv4_connection
|
||||
from plugins.platforms.email.adapter import _create_ipv4_connection
|
||||
|
||||
original_getaddrinfo = _socket.getaddrinfo
|
||||
fake_sock = MagicMock()
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -38,8 +38,8 @@ def _ensure_feishu_mocks():
|
|||
_ensure_feishu_mocks()
|
||||
|
||||
from gateway.config import PlatformConfig
|
||||
import gateway.platforms.feishu as feishu_module
|
||||
from gateway.platforms.feishu import FeishuAdapter
|
||||
import plugins.platforms.feishu.adapter as feishu_module
|
||||
from plugins.platforms.feishu.adapter import FeishuAdapter
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -28,7 +28,7 @@ from tests.gateway.feishu_helpers import (
|
|||
],
|
||||
)
|
||||
def test_feishu_load_settings_populates_allow_bots(monkeypatch, env_value, expected):
|
||||
from gateway.platforms.feishu import FeishuAdapter
|
||||
from plugins.platforms.feishu.adapter import FeishuAdapter
|
||||
|
||||
monkeypatch.setenv("FEISHU_APP_ID", "cli_test")
|
||||
monkeypatch.setenv("FEISHU_APP_SECRET", "secret_test")
|
||||
|
|
@ -39,7 +39,7 @@ def test_feishu_load_settings_populates_allow_bots(monkeypatch, env_value, expec
|
|||
|
||||
|
||||
def test_feishu_load_settings_allow_bots_defaults_to_none(monkeypatch):
|
||||
from gateway.platforms.feishu import FeishuAdapter
|
||||
from plugins.platforms.feishu.adapter import FeishuAdapter
|
||||
|
||||
monkeypatch.setenv("FEISHU_APP_ID", "cli_test")
|
||||
monkeypatch.setenv("FEISHU_APP_SECRET", "secret_test")
|
||||
|
|
@ -51,7 +51,7 @@ def test_feishu_load_settings_allow_bots_defaults_to_none(monkeypatch):
|
|||
|
||||
def test_feishu_load_settings_ignores_extra_allow_bots(monkeypatch):
|
||||
# extra is ignored — env is single source of truth (yaml is bridged to env).
|
||||
from gateway.platforms.feishu import FeishuAdapter
|
||||
from plugins.platforms.feishu.adapter import FeishuAdapter
|
||||
|
||||
monkeypatch.setenv("FEISHU_APP_ID", "cli_test")
|
||||
monkeypatch.setenv("FEISHU_APP_SECRET", "secret_test")
|
||||
|
|
@ -62,7 +62,7 @@ def test_feishu_load_settings_ignores_extra_allow_bots(monkeypatch):
|
|||
|
||||
|
||||
def test_feishu_load_settings_falls_back_to_env_when_extra_missing(monkeypatch):
|
||||
from gateway.platforms.feishu import FeishuAdapter
|
||||
from plugins.platforms.feishu.adapter import FeishuAdapter
|
||||
|
||||
monkeypatch.setenv("FEISHU_APP_ID", "cli_test")
|
||||
monkeypatch.setenv("FEISHU_APP_SECRET", "secret_test")
|
||||
|
|
@ -75,13 +75,13 @@ def test_feishu_load_settings_falls_back_to_env_when_extra_missing(monkeypatch):
|
|||
def test_feishu_load_settings_warns_on_unknown_allow_bots(monkeypatch, caplog):
|
||||
import logging
|
||||
|
||||
from gateway.platforms.feishu import FeishuAdapter
|
||||
from plugins.platforms.feishu.adapter import FeishuAdapter
|
||||
|
||||
monkeypatch.setenv("FEISHU_APP_ID", "cli_test")
|
||||
monkeypatch.setenv("FEISHU_APP_SECRET", "secret_test")
|
||||
monkeypatch.setenv("FEISHU_ALLOW_BOTS", "menton") # typo
|
||||
|
||||
with caplog.at_level(logging.WARNING, logger="gateway.platforms.feishu"):
|
||||
with caplog.at_level(logging.WARNING, logger="plugins.platforms.feishu.adapter"):
|
||||
settings = FeishuAdapter._load_settings(extra={})
|
||||
|
||||
assert settings.allow_bots == "none"
|
||||
|
|
@ -98,7 +98,7 @@ def test_feishu_load_settings_warns_on_unknown_allow_bots(monkeypatch, caplog):
|
|||
],
|
||||
)
|
||||
def test_feishu_load_settings_require_mention(monkeypatch, env_value, extra, expected):
|
||||
from gateway.platforms.feishu import FeishuAdapter
|
||||
from plugins.platforms.feishu.adapter import FeishuAdapter
|
||||
|
||||
monkeypatch.setenv("FEISHU_APP_ID", "cli_test")
|
||||
monkeypatch.setenv("FEISHU_APP_SECRET", "secret_test")
|
||||
|
|
@ -112,7 +112,7 @@ def test_feishu_load_settings_require_mention(monkeypatch, env_value, extra, exp
|
|||
|
||||
|
||||
def test_feishu_load_settings_parses_per_group_require_mention(monkeypatch):
|
||||
from gateway.platforms.feishu import FeishuAdapter
|
||||
from plugins.platforms.feishu.adapter import FeishuAdapter
|
||||
|
||||
monkeypatch.setenv("FEISHU_APP_ID", "cli_test")
|
||||
monkeypatch.setenv("FEISHU_APP_SECRET", "secret_test")
|
||||
|
|
@ -133,7 +133,7 @@ def test_feishu_load_settings_parses_per_group_require_mention(monkeypatch):
|
|||
|
||||
|
||||
def test_sender_identity_collects_every_non_empty_id_variant():
|
||||
from gateway.platforms.feishu import _sender_identity
|
||||
from plugins.platforms.feishu.adapter import _sender_identity
|
||||
|
||||
sender = SimpleNamespace(
|
||||
sender_id=SimpleNamespace(open_id="ou_x", user_id="", union_id="un_x"),
|
||||
|
|
@ -142,21 +142,21 @@ def test_sender_identity_collects_every_non_empty_id_variant():
|
|||
|
||||
|
||||
def test_sender_identity_handles_missing_sender_id():
|
||||
from gateway.platforms.feishu import _sender_identity
|
||||
from plugins.platforms.feishu.adapter import _sender_identity
|
||||
|
||||
assert _sender_identity(SimpleNamespace()) == frozenset()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("sender_type", ["bot", "app"])
|
||||
def test_is_bot_sender_treats_bot_and_app_as_bot_origin(sender_type):
|
||||
from gateway.platforms.feishu import _is_bot_sender
|
||||
from plugins.platforms.feishu.adapter import _is_bot_sender
|
||||
|
||||
assert _is_bot_sender(SimpleNamespace(sender_type=sender_type)) is True
|
||||
|
||||
|
||||
@pytest.mark.parametrize("sender_type", ["user", "", None])
|
||||
def test_is_bot_sender_rejects_non_bot_origin(sender_type):
|
||||
from gateway.platforms.feishu import _is_bot_sender
|
||||
from plugins.platforms.feishu.adapter import _is_bot_sender
|
||||
|
||||
assert _is_bot_sender(SimpleNamespace(sender_type=sender_type)) is False
|
||||
|
||||
|
|
@ -430,7 +430,7 @@ def test_admit_group_mention_checked_once_per_call():
|
|||
|
||||
|
||||
def test_admit_per_group_require_mention_overrides_global():
|
||||
from gateway.platforms.feishu import FeishuGroupRule
|
||||
from plugins.platforms.feishu.adapter import FeishuGroupRule
|
||||
|
||||
adapter = make_adapter_skeleton(
|
||||
bot_open_id="ou_self", require_mention=True, group_policy="open",
|
||||
|
|
@ -454,7 +454,7 @@ def test_admit_per_group_require_mention_overrides_global():
|
|||
def test_hydrate_bot_identity_populates_self_ids_from_bot_v3_info(monkeypatch):
|
||||
import asyncio
|
||||
|
||||
from gateway.platforms import feishu as feishu_mod
|
||||
import plugins.platforms.feishu.adapter as feishu_mod
|
||||
FeishuAdapter = feishu_mod.FeishuAdapter
|
||||
|
||||
class _FakeBaseRequestBuilder:
|
||||
|
|
@ -515,7 +515,7 @@ def test_hydrate_bot_identity_populates_self_ids_from_bot_v3_info(monkeypatch):
|
|||
def test_resolve_sender_profile_uses_open_id_for_bot_name_lookup():
|
||||
import asyncio
|
||||
|
||||
from gateway.platforms.feishu import FeishuAdapter
|
||||
from plugins.platforms.feishu.adapter import FeishuAdapter
|
||||
|
||||
adapter = object.__new__(FeishuAdapter)
|
||||
adapter._client = object()
|
||||
|
|
@ -569,7 +569,7 @@ def _group_case(
|
|||
|
||||
|
||||
def _group_rule(policy: str, **kwargs):
|
||||
from gateway.platforms.feishu import FeishuGroupRule
|
||||
from plugins.platforms.feishu.adapter import FeishuGroupRule
|
||||
return FeishuGroupRule(policy=policy, **kwargs)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import unittest
|
|||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, Mock, patch
|
||||
|
||||
from gateway.platforms.feishu_comment import (
|
||||
from plugins.platforms.feishu.feishu_comment import (
|
||||
parse_drive_comment_event,
|
||||
_ALLOWED_NOTICE_TYPES,
|
||||
_sanitize_comment_text,
|
||||
|
|
@ -62,45 +62,45 @@ class TestEventFiltering(unittest.TestCase):
|
|||
def _run(self, coro):
|
||||
return asyncio.get_event_loop().run_until_complete(coro)
|
||||
|
||||
@patch("gateway.platforms.feishu_comment_rules.load_config")
|
||||
@patch("gateway.platforms.feishu_comment_rules.resolve_rule")
|
||||
@patch("gateway.platforms.feishu_comment_rules.is_user_allowed")
|
||||
@patch("plugins.platforms.feishu.feishu_comment_rules.load_config")
|
||||
@patch("plugins.platforms.feishu.feishu_comment_rules.resolve_rule")
|
||||
@patch("plugins.platforms.feishu.feishu_comment_rules.is_user_allowed")
|
||||
def test_self_reply_filtered(self, mock_allowed, mock_resolve, mock_load):
|
||||
"""Events where from_open_id == self_open_id should be dropped."""
|
||||
from gateway.platforms.feishu_comment import handle_drive_comment_event
|
||||
from plugins.platforms.feishu.feishu_comment import handle_drive_comment_event
|
||||
|
||||
evt = _make_event(from_open_id="ou_bot", to_open_id="ou_bot")
|
||||
self._run(handle_drive_comment_event(Mock(), evt, self_open_id="ou_bot"))
|
||||
mock_load.assert_not_called()
|
||||
|
||||
@patch("gateway.platforms.feishu_comment_rules.load_config")
|
||||
@patch("gateway.platforms.feishu_comment_rules.resolve_rule")
|
||||
@patch("gateway.platforms.feishu_comment_rules.is_user_allowed")
|
||||
@patch("plugins.platforms.feishu.feishu_comment_rules.load_config")
|
||||
@patch("plugins.platforms.feishu.feishu_comment_rules.resolve_rule")
|
||||
@patch("plugins.platforms.feishu.feishu_comment_rules.is_user_allowed")
|
||||
def test_wrong_receiver_filtered(self, mock_allowed, mock_resolve, mock_load):
|
||||
"""Events where to_open_id != self_open_id should be dropped."""
|
||||
from gateway.platforms.feishu_comment import handle_drive_comment_event
|
||||
from plugins.platforms.feishu.feishu_comment import handle_drive_comment_event
|
||||
|
||||
evt = _make_event(to_open_id="ou_other_bot")
|
||||
self._run(handle_drive_comment_event(Mock(), evt, self_open_id="ou_bot"))
|
||||
mock_load.assert_not_called()
|
||||
|
||||
@patch("gateway.platforms.feishu_comment_rules.load_config")
|
||||
@patch("gateway.platforms.feishu_comment_rules.resolve_rule")
|
||||
@patch("gateway.platforms.feishu_comment_rules.is_user_allowed")
|
||||
@patch("plugins.platforms.feishu.feishu_comment_rules.load_config")
|
||||
@patch("plugins.platforms.feishu.feishu_comment_rules.resolve_rule")
|
||||
@patch("plugins.platforms.feishu.feishu_comment_rules.is_user_allowed")
|
||||
def test_empty_to_open_id_filtered(self, mock_allowed, mock_resolve, mock_load):
|
||||
"""Events with empty to_open_id should be dropped."""
|
||||
from gateway.platforms.feishu_comment import handle_drive_comment_event
|
||||
from plugins.platforms.feishu.feishu_comment import handle_drive_comment_event
|
||||
|
||||
evt = _make_event(to_open_id="")
|
||||
self._run(handle_drive_comment_event(Mock(), evt, self_open_id="ou_bot"))
|
||||
mock_load.assert_not_called()
|
||||
|
||||
@patch("gateway.platforms.feishu_comment_rules.load_config")
|
||||
@patch("gateway.platforms.feishu_comment_rules.resolve_rule")
|
||||
@patch("gateway.platforms.feishu_comment_rules.is_user_allowed")
|
||||
@patch("plugins.platforms.feishu.feishu_comment_rules.load_config")
|
||||
@patch("plugins.platforms.feishu.feishu_comment_rules.resolve_rule")
|
||||
@patch("plugins.platforms.feishu.feishu_comment_rules.is_user_allowed")
|
||||
def test_invalid_notice_type_filtered(self, mock_allowed, mock_resolve, mock_load):
|
||||
"""Events with unsupported notice_type should be dropped."""
|
||||
from gateway.platforms.feishu_comment import handle_drive_comment_event
|
||||
from plugins.platforms.feishu.feishu_comment import handle_drive_comment_event
|
||||
|
||||
evt = _make_event(notice_type="resolve_comment")
|
||||
self._run(handle_drive_comment_event(Mock(), evt, self_open_id="ou_bot"))
|
||||
|
|
@ -116,14 +116,14 @@ class TestAccessControlIntegration(unittest.TestCase):
|
|||
def _run(self, coro):
|
||||
return asyncio.get_event_loop().run_until_complete(coro)
|
||||
|
||||
@patch("gateway.platforms.feishu_comment_rules.has_wiki_keys", return_value=False)
|
||||
@patch("gateway.platforms.feishu_comment_rules.is_user_allowed", return_value=False)
|
||||
@patch("gateway.platforms.feishu_comment_rules.resolve_rule")
|
||||
@patch("gateway.platforms.feishu_comment_rules.load_config")
|
||||
@patch("plugins.platforms.feishu.feishu_comment_rules.has_wiki_keys", return_value=False)
|
||||
@patch("plugins.platforms.feishu.feishu_comment_rules.is_user_allowed", return_value=False)
|
||||
@patch("plugins.platforms.feishu.feishu_comment_rules.resolve_rule")
|
||||
@patch("plugins.platforms.feishu.feishu_comment_rules.load_config")
|
||||
def test_denied_user_no_side_effects(self, mock_load, mock_resolve, mock_allowed, mock_wiki_keys):
|
||||
"""Denied user should not trigger typing reaction or agent."""
|
||||
from gateway.platforms.feishu_comment import handle_drive_comment_event
|
||||
from gateway.platforms.feishu_comment_rules import ResolvedCommentRule
|
||||
from plugins.platforms.feishu.feishu_comment import handle_drive_comment_event
|
||||
from plugins.platforms.feishu.feishu_comment_rules import ResolvedCommentRule
|
||||
|
||||
mock_resolve.return_value = ResolvedCommentRule(True, "allowlist", frozenset(), "top")
|
||||
mock_load.return_value = Mock()
|
||||
|
|
@ -135,14 +135,14 @@ class TestAccessControlIntegration(unittest.TestCase):
|
|||
# No API calls should be made for denied users
|
||||
client.request.assert_not_called()
|
||||
|
||||
@patch("gateway.platforms.feishu_comment_rules.has_wiki_keys", return_value=False)
|
||||
@patch("gateway.platforms.feishu_comment_rules.is_user_allowed", return_value=False)
|
||||
@patch("gateway.platforms.feishu_comment_rules.resolve_rule")
|
||||
@patch("gateway.platforms.feishu_comment_rules.load_config")
|
||||
@patch("plugins.platforms.feishu.feishu_comment_rules.has_wiki_keys", return_value=False)
|
||||
@patch("plugins.platforms.feishu.feishu_comment_rules.is_user_allowed", return_value=False)
|
||||
@patch("plugins.platforms.feishu.feishu_comment_rules.resolve_rule")
|
||||
@patch("plugins.platforms.feishu.feishu_comment_rules.load_config")
|
||||
def test_disabled_comment_skipped(self, mock_load, mock_resolve, mock_allowed, mock_wiki_keys):
|
||||
"""Disabled comments should return immediately."""
|
||||
from gateway.platforms.feishu_comment import handle_drive_comment_event
|
||||
from gateway.platforms.feishu_comment_rules import ResolvedCommentRule
|
||||
from plugins.platforms.feishu.feishu_comment import handle_drive_comment_event
|
||||
from plugins.platforms.feishu.feishu_comment_rules import ResolvedCommentRule
|
||||
|
||||
mock_resolve.return_value = ResolvedCommentRule(False, "allowlist", frozenset(), "top")
|
||||
mock_load.return_value = Mock()
|
||||
|
|
@ -184,9 +184,9 @@ class TestWikiReverseLookup(unittest.TestCase):
|
|||
def _run(self, coro):
|
||||
return asyncio.get_event_loop().run_until_complete(coro)
|
||||
|
||||
@patch("gateway.platforms.feishu_comment._exec_request")
|
||||
@patch("plugins.platforms.feishu.feishu_comment._exec_request")
|
||||
def test_reverse_lookup_success(self, mock_exec):
|
||||
from gateway.platforms.feishu_comment import _reverse_lookup_wiki_token
|
||||
from plugins.platforms.feishu.feishu_comment import _reverse_lookup_wiki_token
|
||||
|
||||
mock_exec.return_value = (0, "Success", {
|
||||
"node": {"node_token": "WIKI_TOKEN_123", "obj_token": "docx_abc"},
|
||||
|
|
@ -200,37 +200,37 @@ class TestWikiReverseLookup(unittest.TestCase):
|
|||
self.assertEqual(query_dict["token"], "docx_abc")
|
||||
self.assertEqual(query_dict["obj_type"], "docx")
|
||||
|
||||
@patch("gateway.platforms.feishu_comment._exec_request")
|
||||
@patch("plugins.platforms.feishu.feishu_comment._exec_request")
|
||||
def test_reverse_lookup_not_wiki(self, mock_exec):
|
||||
from gateway.platforms.feishu_comment import _reverse_lookup_wiki_token
|
||||
from plugins.platforms.feishu.feishu_comment import _reverse_lookup_wiki_token
|
||||
|
||||
mock_exec.return_value = (131001, "not found", {})
|
||||
result = self._run(_reverse_lookup_wiki_token(Mock(), "docx", "docx_abc"))
|
||||
self.assertIsNone(result)
|
||||
|
||||
@patch("gateway.platforms.feishu_comment._exec_request")
|
||||
@patch("plugins.platforms.feishu.feishu_comment._exec_request")
|
||||
def test_reverse_lookup_service_error(self, mock_exec):
|
||||
from gateway.platforms.feishu_comment import _reverse_lookup_wiki_token
|
||||
from plugins.platforms.feishu.feishu_comment import _reverse_lookup_wiki_token
|
||||
|
||||
mock_exec.return_value = (500, "internal error", {})
|
||||
result = self._run(_reverse_lookup_wiki_token(Mock(), "docx", "docx_abc"))
|
||||
self.assertIsNone(result)
|
||||
|
||||
@patch("gateway.platforms.feishu_comment._reverse_lookup_wiki_token", new_callable=AsyncMock)
|
||||
@patch("gateway.platforms.feishu_comment_rules.has_wiki_keys", return_value=True)
|
||||
@patch("gateway.platforms.feishu_comment_rules.is_user_allowed", return_value=True)
|
||||
@patch("gateway.platforms.feishu_comment_rules.resolve_rule")
|
||||
@patch("gateway.platforms.feishu_comment_rules.load_config")
|
||||
@patch("gateway.platforms.feishu_comment.add_comment_reaction", new_callable=AsyncMock)
|
||||
@patch("gateway.platforms.feishu_comment.batch_query_comment", new_callable=AsyncMock)
|
||||
@patch("gateway.platforms.feishu_comment.query_document_meta", new_callable=AsyncMock)
|
||||
@patch("plugins.platforms.feishu.feishu_comment._reverse_lookup_wiki_token", new_callable=AsyncMock)
|
||||
@patch("plugins.platforms.feishu.feishu_comment_rules.has_wiki_keys", return_value=True)
|
||||
@patch("plugins.platforms.feishu.feishu_comment_rules.is_user_allowed", return_value=True)
|
||||
@patch("plugins.platforms.feishu.feishu_comment_rules.resolve_rule")
|
||||
@patch("plugins.platforms.feishu.feishu_comment_rules.load_config")
|
||||
@patch("plugins.platforms.feishu.feishu_comment.add_comment_reaction", new_callable=AsyncMock)
|
||||
@patch("plugins.platforms.feishu.feishu_comment.batch_query_comment", new_callable=AsyncMock)
|
||||
@patch("plugins.platforms.feishu.feishu_comment.query_document_meta", new_callable=AsyncMock)
|
||||
def test_wiki_lookup_triggered_when_no_exact_match(
|
||||
self, mock_meta, mock_batch, mock_reaction,
|
||||
mock_load, mock_resolve, mock_allowed, mock_wiki_keys, mock_lookup,
|
||||
):
|
||||
"""Wiki reverse lookup should fire when rule falls to wildcard/top and wiki keys exist."""
|
||||
from gateway.platforms.feishu_comment import handle_drive_comment_event
|
||||
from gateway.platforms.feishu_comment_rules import ResolvedCommentRule
|
||||
from plugins.platforms.feishu.feishu_comment import handle_drive_comment_event
|
||||
from plugins.platforms.feishu.feishu_comment_rules import ResolvedCommentRule
|
||||
|
||||
# First resolve returns wildcard (no exact match), second returns exact wiki match
|
||||
mock_resolve.side_effect = [
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ import unittest
|
|||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
from gateway.platforms.feishu_comment_rules import (
|
||||
from plugins.platforms.feishu.feishu_comment_rules import (
|
||||
CommentsConfig,
|
||||
CommentDocumentRule,
|
||||
ResolvedCommentRule,
|
||||
|
|
@ -195,7 +195,7 @@ class TestIsUserAllowed(unittest.TestCase):
|
|||
def test_pairing_checks_store(self):
|
||||
rule = ResolvedCommentRule(True, "pairing", frozenset(), "top")
|
||||
with patch(
|
||||
"gateway.platforms.feishu_comment_rules._load_pairing_approved",
|
||||
"plugins.platforms.feishu.feishu_comment_rules._load_pairing_approved",
|
||||
return_value={"ou_approved"},
|
||||
):
|
||||
self.assertTrue(is_user_allowed(rule, "ou_approved"))
|
||||
|
|
@ -256,8 +256,8 @@ class TestLoadConfig(unittest.TestCase):
|
|||
json.dump(raw, f)
|
||||
path = Path(f.name)
|
||||
try:
|
||||
with patch("gateway.platforms.feishu_comment_rules.RULES_FILE", path):
|
||||
with patch("gateway.platforms.feishu_comment_rules._rules_cache", _MtimeCache(path)):
|
||||
with patch("plugins.platforms.feishu.feishu_comment_rules.RULES_FILE", path):
|
||||
with patch("plugins.platforms.feishu.feishu_comment_rules._rules_cache", _MtimeCache(path)):
|
||||
cfg = load_config()
|
||||
self.assertTrue(cfg.enabled)
|
||||
self.assertEqual(cfg.policy, "allowlist")
|
||||
|
|
@ -269,7 +269,7 @@ class TestLoadConfig(unittest.TestCase):
|
|||
path.unlink()
|
||||
|
||||
def test_load_missing_file_returns_defaults(self):
|
||||
with patch("gateway.platforms.feishu_comment_rules._rules_cache", _MtimeCache(Path("/nonexistent"))):
|
||||
with patch("plugins.platforms.feishu.feishu_comment_rules._rules_cache", _MtimeCache(Path("/nonexistent"))):
|
||||
cfg = load_config()
|
||||
self.assertTrue(cfg.enabled)
|
||||
self.assertEqual(cfg.policy, "pairing")
|
||||
|
|
@ -283,9 +283,9 @@ class TestPairingStore(unittest.TestCase):
|
|||
self._pairing_file = Path(self._tmpdir) / "pairing.json"
|
||||
with open(self._pairing_file, "w") as f:
|
||||
json.dump({"approved": {}}, f)
|
||||
self._patcher_file = patch("gateway.platforms.feishu_comment_rules.PAIRING_FILE", self._pairing_file)
|
||||
self._patcher_file = patch("plugins.platforms.feishu.feishu_comment_rules.PAIRING_FILE", self._pairing_file)
|
||||
self._patcher_cache = patch(
|
||||
"gateway.platforms.feishu_comment_rules._pairing_cache",
|
||||
"plugins.platforms.feishu.feishu_comment_rules._pairing_cache",
|
||||
_MtimeCache(self._pairing_file),
|
||||
)
|
||||
self._patcher_file.start()
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ from types import SimpleNamespace
|
|||
from unittest.mock import patch
|
||||
|
||||
from gateway.platforms.base import MessageEvent
|
||||
from gateway.platforms.feishu_meeting_invite import (
|
||||
from plugins.platforms.feishu.feishu_meeting_invite import (
|
||||
build_meeting_invite_prompt,
|
||||
handle_meeting_invited_event,
|
||||
parse_meeting_invited_event,
|
||||
|
|
@ -212,7 +212,7 @@ class TestMeetingInviteSendRouting(unittest.TestCase):
|
|||
|
||||
def test_feishu_user_id_prefix_sends_with_user_id_receive_type(self):
|
||||
from gateway.config import PlatformConfig
|
||||
from gateway.platforms.feishu import FeishuAdapter
|
||||
from plugins.platforms.feishu.adapter import FeishuAdapter
|
||||
|
||||
created_requests = []
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
"""Tests for gateway.platforms.feishu — Feishu scan-to-create registration."""
|
||||
"""Tests for plugins.platforms.feishu.adapter — Feishu scan-to-create registration."""
|
||||
|
||||
import json
|
||||
from unittest.mock import patch, MagicMock
|
||||
|
|
@ -18,18 +18,18 @@ def _mock_urlopen(response_data, status=200):
|
|||
class TestPostRegistration:
|
||||
"""Tests for the low-level HTTP helper."""
|
||||
|
||||
@patch("gateway.platforms.feishu.urlopen")
|
||||
@patch("plugins.platforms.feishu.adapter.urlopen")
|
||||
def test_post_registration_returns_parsed_json(self, mock_urlopen_fn):
|
||||
from gateway.platforms.feishu import _post_registration
|
||||
from plugins.platforms.feishu.adapter import _post_registration
|
||||
|
||||
mock_urlopen_fn.return_value = _mock_urlopen({"nonce": "abc", "supported_auth_methods": ["client_secret"]})
|
||||
result = _post_registration("https://accounts.feishu.cn", {"action": "init"})
|
||||
assert result["nonce"] == "abc"
|
||||
assert "client_secret" in result["supported_auth_methods"]
|
||||
|
||||
@patch("gateway.platforms.feishu.urlopen")
|
||||
@patch("plugins.platforms.feishu.adapter.urlopen")
|
||||
def test_post_registration_sends_form_encoded_body(self, mock_urlopen_fn):
|
||||
from gateway.platforms.feishu import _post_registration
|
||||
from plugins.platforms.feishu.adapter import _post_registration
|
||||
|
||||
mock_urlopen_fn.return_value = _mock_urlopen({})
|
||||
_post_registration("https://accounts.feishu.cn", {"action": "init", "key": "val"})
|
||||
|
|
@ -44,9 +44,9 @@ class TestPostRegistration:
|
|||
class TestInitRegistration:
|
||||
"""Tests for the init step."""
|
||||
|
||||
@patch("gateway.platforms.feishu.urlopen")
|
||||
@patch("plugins.platforms.feishu.adapter.urlopen")
|
||||
def test_init_succeeds_when_client_secret_supported(self, mock_urlopen_fn):
|
||||
from gateway.platforms.feishu import _init_registration
|
||||
from plugins.platforms.feishu.adapter import _init_registration
|
||||
|
||||
mock_urlopen_fn.return_value = _mock_urlopen({
|
||||
"nonce": "abc",
|
||||
|
|
@ -54,9 +54,9 @@ class TestInitRegistration:
|
|||
})
|
||||
_init_registration("feishu")
|
||||
|
||||
@patch("gateway.platforms.feishu.urlopen")
|
||||
@patch("plugins.platforms.feishu.adapter.urlopen")
|
||||
def test_init_raises_when_client_secret_not_supported(self, mock_urlopen_fn):
|
||||
from gateway.platforms.feishu import _init_registration
|
||||
from plugins.platforms.feishu.adapter import _init_registration
|
||||
|
||||
mock_urlopen_fn.return_value = _mock_urlopen({
|
||||
"nonce": "abc",
|
||||
|
|
@ -65,9 +65,9 @@ class TestInitRegistration:
|
|||
with pytest.raises(RuntimeError, match="client_secret"):
|
||||
_init_registration("feishu")
|
||||
|
||||
@patch("gateway.platforms.feishu.urlopen")
|
||||
@patch("plugins.platforms.feishu.adapter.urlopen")
|
||||
def test_init_uses_lark_url_for_lark_domain(self, mock_urlopen_fn):
|
||||
from gateway.platforms.feishu import _init_registration
|
||||
from plugins.platforms.feishu.adapter import _init_registration
|
||||
|
||||
mock_urlopen_fn.return_value = _mock_urlopen({
|
||||
"nonce": "abc",
|
||||
|
|
@ -82,9 +82,9 @@ class TestInitRegistration:
|
|||
class TestBeginRegistration:
|
||||
"""Tests for the begin step."""
|
||||
|
||||
@patch("gateway.platforms.feishu.urlopen")
|
||||
@patch("plugins.platforms.feishu.adapter.urlopen")
|
||||
def test_begin_returns_device_code_and_qr_url(self, mock_urlopen_fn):
|
||||
from gateway.platforms.feishu import _begin_registration
|
||||
from plugins.platforms.feishu.adapter import _begin_registration
|
||||
|
||||
mock_urlopen_fn.return_value = _mock_urlopen({
|
||||
"device_code": "dc_123",
|
||||
|
|
@ -101,9 +101,9 @@ class TestBeginRegistration:
|
|||
assert result["interval"] == 5
|
||||
assert result["expire_in"] == 600
|
||||
|
||||
@patch("gateway.platforms.feishu.urlopen")
|
||||
@patch("plugins.platforms.feishu.adapter.urlopen")
|
||||
def test_begin_sends_correct_archetype(self, mock_urlopen_fn):
|
||||
from gateway.platforms.feishu import _begin_registration
|
||||
from plugins.platforms.feishu.adapter import _begin_registration
|
||||
|
||||
mock_urlopen_fn.return_value = _mock_urlopen({
|
||||
"device_code": "dc_123",
|
||||
|
|
@ -122,10 +122,10 @@ class TestBeginRegistration:
|
|||
class TestPollRegistration:
|
||||
"""Tests for the poll step."""
|
||||
|
||||
@patch("gateway.platforms.feishu.time")
|
||||
@patch("gateway.platforms.feishu.urlopen")
|
||||
@patch("plugins.platforms.feishu.adapter.time")
|
||||
@patch("plugins.platforms.feishu.adapter.urlopen")
|
||||
def test_poll_returns_credentials_on_success(self, mock_urlopen_fn, mock_time):
|
||||
from gateway.platforms.feishu import _poll_registration
|
||||
from plugins.platforms.feishu.adapter import _poll_registration
|
||||
|
||||
mock_time.monotonic.side_effect = [0, 1]
|
||||
mock_time.sleep = MagicMock()
|
||||
|
|
@ -144,10 +144,10 @@ class TestPollRegistration:
|
|||
assert result["domain"] == "feishu"
|
||||
assert result["open_id"] == "ou_owner"
|
||||
|
||||
@patch("gateway.platforms.feishu.time")
|
||||
@patch("gateway.platforms.feishu.urlopen")
|
||||
@patch("plugins.platforms.feishu.adapter.time")
|
||||
@patch("plugins.platforms.feishu.adapter.urlopen")
|
||||
def test_poll_switches_domain_on_lark_tenant_brand(self, mock_urlopen_fn, mock_time):
|
||||
from gateway.platforms.feishu import _poll_registration
|
||||
from plugins.platforms.feishu.adapter import _poll_registration
|
||||
|
||||
mock_time.monotonic.side_effect = [0, 1, 2]
|
||||
mock_time.sleep = MagicMock()
|
||||
|
|
@ -169,11 +169,11 @@ class TestPollRegistration:
|
|||
assert result is not None
|
||||
assert result["domain"] == "lark"
|
||||
|
||||
@patch("gateway.platforms.feishu.time")
|
||||
@patch("gateway.platforms.feishu.urlopen")
|
||||
@patch("plugins.platforms.feishu.adapter.time")
|
||||
@patch("plugins.platforms.feishu.adapter.urlopen")
|
||||
def test_poll_success_with_lark_brand_in_same_response(self, mock_urlopen_fn, mock_time):
|
||||
"""Credentials and lark tenant_brand in one response must not be discarded."""
|
||||
from gateway.platforms.feishu import _poll_registration
|
||||
from plugins.platforms.feishu.adapter import _poll_registration
|
||||
|
||||
mock_time.monotonic.side_effect = [0, 1]
|
||||
mock_time.sleep = MagicMock()
|
||||
|
|
@ -191,10 +191,10 @@ class TestPollRegistration:
|
|||
assert result["domain"] == "lark"
|
||||
assert result["open_id"] == "ou_lark_direct"
|
||||
|
||||
@patch("gateway.platforms.feishu.time")
|
||||
@patch("gateway.platforms.feishu.urlopen")
|
||||
@patch("plugins.platforms.feishu.adapter.time")
|
||||
@patch("plugins.platforms.feishu.adapter.urlopen")
|
||||
def test_poll_returns_none_on_access_denied(self, mock_urlopen_fn, mock_time):
|
||||
from gateway.platforms.feishu import _poll_registration
|
||||
from plugins.platforms.feishu.adapter import _poll_registration
|
||||
|
||||
mock_time.monotonic.side_effect = [0, 1]
|
||||
mock_time.sleep = MagicMock()
|
||||
|
|
@ -207,10 +207,10 @@ class TestPollRegistration:
|
|||
)
|
||||
assert result is None
|
||||
|
||||
@patch("gateway.platforms.feishu.time")
|
||||
@patch("gateway.platforms.feishu.urlopen")
|
||||
@patch("plugins.platforms.feishu.adapter.time")
|
||||
@patch("plugins.platforms.feishu.adapter.urlopen")
|
||||
def test_poll_returns_none_on_timeout(self, mock_urlopen_fn, mock_time):
|
||||
from gateway.platforms.feishu import _poll_registration
|
||||
from plugins.platforms.feishu.adapter import _poll_registration
|
||||
|
||||
mock_time.monotonic.side_effect = [0, 999]
|
||||
mock_time.sleep = MagicMock()
|
||||
|
|
@ -223,10 +223,10 @@ class TestPollRegistration:
|
|||
)
|
||||
assert result is None
|
||||
|
||||
@patch("gateway.platforms.feishu.time")
|
||||
@patch("gateway.platforms.feishu.urlopen")
|
||||
@patch("plugins.platforms.feishu.adapter.time")
|
||||
@patch("plugins.platforms.feishu.adapter.urlopen")
|
||||
def test_poll_timeout_uses_monotonic_clock(self, mock_urlopen_fn, mock_time):
|
||||
from gateway.platforms.feishu import _poll_registration
|
||||
from plugins.platforms.feishu.adapter import _poll_registration
|
||||
|
||||
mock_time.monotonic.side_effect = [1000, 1000.2, 1001.1]
|
||||
mock_time.time.side_effect = [1000, 900, 901, 902]
|
||||
|
|
@ -246,9 +246,9 @@ class TestPollRegistration:
|
|||
class TestRenderQr:
|
||||
"""Tests for QR code terminal rendering."""
|
||||
|
||||
@patch("gateway.platforms.feishu._qrcode_mod", create=True)
|
||||
@patch("plugins.platforms.feishu.adapter._qrcode_mod", create=True)
|
||||
def test_render_qr_returns_true_on_success(self, mock_qrcode_mod):
|
||||
from gateway.platforms.feishu import _render_qr
|
||||
from plugins.platforms.feishu.adapter import _render_qr
|
||||
|
||||
mock_qr = MagicMock()
|
||||
mock_qrcode_mod.QRCode.return_value = mock_qr
|
||||
|
|
@ -258,20 +258,20 @@ class TestRenderQr:
|
|||
mock_qr.print_ascii.assert_called_once()
|
||||
|
||||
def test_render_qr_returns_false_when_qrcode_missing(self):
|
||||
from gateway.platforms.feishu import _render_qr
|
||||
from plugins.platforms.feishu.adapter import _render_qr
|
||||
|
||||
with patch("gateway.platforms.feishu._qrcode_mod", None):
|
||||
with patch("plugins.platforms.feishu.adapter._qrcode_mod", None):
|
||||
assert _render_qr("https://example.com/qr") is False
|
||||
|
||||
|
||||
class TestProbeBot:
|
||||
"""Tests for bot connectivity verification."""
|
||||
|
||||
@patch("gateway.platforms.feishu.FEISHU_AVAILABLE", True)
|
||||
@patch("plugins.platforms.feishu.adapter.FEISHU_AVAILABLE", True)
|
||||
def test_probe_returns_bot_info_on_success(self):
|
||||
from gateway.platforms.feishu import probe_bot
|
||||
from plugins.platforms.feishu.adapter import probe_bot
|
||||
|
||||
with patch("gateway.platforms.feishu._probe_bot_sdk") as mock_sdk:
|
||||
with patch("plugins.platforms.feishu.adapter._probe_bot_sdk") as mock_sdk:
|
||||
mock_sdk.return_value = {"bot_name": "TestBot", "bot_open_id": "ou_bot123"}
|
||||
result = probe_bot("cli_app", "secret", "feishu")
|
||||
|
||||
|
|
@ -279,21 +279,21 @@ class TestProbeBot:
|
|||
assert result["bot_name"] == "TestBot"
|
||||
assert result["bot_open_id"] == "ou_bot123"
|
||||
|
||||
@patch("gateway.platforms.feishu.FEISHU_AVAILABLE", True)
|
||||
@patch("plugins.platforms.feishu.adapter.FEISHU_AVAILABLE", True)
|
||||
def test_probe_returns_none_on_failure(self):
|
||||
from gateway.platforms.feishu import probe_bot
|
||||
from plugins.platforms.feishu.adapter import probe_bot
|
||||
|
||||
with patch("gateway.platforms.feishu._probe_bot_sdk") as mock_sdk:
|
||||
with patch("plugins.platforms.feishu.adapter._probe_bot_sdk") as mock_sdk:
|
||||
mock_sdk.return_value = None
|
||||
result = probe_bot("bad_id", "bad_secret", "feishu")
|
||||
|
||||
assert result is None
|
||||
|
||||
@patch("gateway.platforms.feishu.FEISHU_AVAILABLE", False)
|
||||
@patch("gateway.platforms.feishu.urlopen")
|
||||
@patch("plugins.platforms.feishu.adapter.FEISHU_AVAILABLE", False)
|
||||
@patch("plugins.platforms.feishu.adapter.urlopen")
|
||||
def test_http_fallback_when_sdk_unavailable(self, mock_urlopen_fn):
|
||||
"""Without lark_oapi, probe falls back to raw HTTP."""
|
||||
from gateway.platforms.feishu import probe_bot
|
||||
from plugins.platforms.feishu.adapter import probe_bot
|
||||
|
||||
token_resp = _mock_urlopen({"code": 0, "tenant_access_token": "t-123"})
|
||||
bot_resp = _mock_urlopen({"code": 0, "bot": {"bot_name": "HttpBot", "open_id": "ou_http"}})
|
||||
|
|
@ -303,10 +303,10 @@ class TestProbeBot:
|
|||
assert result is not None
|
||||
assert result["bot_name"] == "HttpBot"
|
||||
|
||||
@patch("gateway.platforms.feishu.FEISHU_AVAILABLE", False)
|
||||
@patch("gateway.platforms.feishu.urlopen")
|
||||
@patch("plugins.platforms.feishu.adapter.FEISHU_AVAILABLE", False)
|
||||
@patch("plugins.platforms.feishu.adapter.urlopen")
|
||||
def test_http_fallback_returns_none_on_network_error(self, mock_urlopen_fn):
|
||||
from gateway.platforms.feishu import probe_bot
|
||||
from plugins.platforms.feishu.adapter import probe_bot
|
||||
from urllib.error import URLError
|
||||
|
||||
mock_urlopen_fn.side_effect = URLError("connection refused")
|
||||
|
|
@ -317,15 +317,15 @@ class TestProbeBot:
|
|||
class TestQrRegister:
|
||||
"""Tests for the public qr_register entry point."""
|
||||
|
||||
@patch("gateway.platforms.feishu.probe_bot")
|
||||
@patch("gateway.platforms.feishu._render_qr")
|
||||
@patch("gateway.platforms.feishu._poll_registration")
|
||||
@patch("gateway.platforms.feishu._begin_registration")
|
||||
@patch("gateway.platforms.feishu._init_registration")
|
||||
@patch("plugins.platforms.feishu.adapter.probe_bot")
|
||||
@patch("plugins.platforms.feishu.adapter._render_qr")
|
||||
@patch("plugins.platforms.feishu.adapter._poll_registration")
|
||||
@patch("plugins.platforms.feishu.adapter._begin_registration")
|
||||
@patch("plugins.platforms.feishu.adapter._init_registration")
|
||||
def test_qr_register_success_flow(
|
||||
self, mock_init, mock_begin, mock_poll, mock_render, mock_probe
|
||||
):
|
||||
from gateway.platforms.feishu import qr_register
|
||||
from plugins.platforms.feishu.adapter import qr_register
|
||||
|
||||
mock_begin.return_value = {
|
||||
"device_code": "dc_123",
|
||||
|
|
@ -350,22 +350,22 @@ class TestQrRegister:
|
|||
mock_init.assert_called_once()
|
||||
mock_render.assert_called_once()
|
||||
|
||||
@patch("gateway.platforms.feishu._init_registration")
|
||||
@patch("plugins.platforms.feishu.adapter._init_registration")
|
||||
def test_qr_register_returns_none_on_init_failure(self, mock_init):
|
||||
from gateway.platforms.feishu import qr_register
|
||||
from plugins.platforms.feishu.adapter import qr_register
|
||||
|
||||
mock_init.side_effect = RuntimeError("not supported")
|
||||
result = qr_register()
|
||||
assert result is None
|
||||
|
||||
@patch("gateway.platforms.feishu._render_qr")
|
||||
@patch("gateway.platforms.feishu._poll_registration")
|
||||
@patch("gateway.platforms.feishu._begin_registration")
|
||||
@patch("gateway.platforms.feishu._init_registration")
|
||||
@patch("plugins.platforms.feishu.adapter._render_qr")
|
||||
@patch("plugins.platforms.feishu.adapter._poll_registration")
|
||||
@patch("plugins.platforms.feishu.adapter._begin_registration")
|
||||
@patch("plugins.platforms.feishu.adapter._init_registration")
|
||||
def test_qr_register_returns_none_on_poll_failure(
|
||||
self, mock_init, mock_begin, mock_poll, mock_render
|
||||
):
|
||||
from gateway.platforms.feishu import qr_register
|
||||
from plugins.platforms.feishu.adapter import qr_register
|
||||
|
||||
mock_begin.return_value = {
|
||||
"device_code": "dc_123",
|
||||
|
|
@ -381,29 +381,29 @@ class TestQrRegister:
|
|||
|
||||
# -- Contract: expected errors → None, unexpected errors → propagate --
|
||||
|
||||
@patch("gateway.platforms.feishu._init_registration")
|
||||
@patch("plugins.platforms.feishu.adapter._init_registration")
|
||||
def test_qr_register_returns_none_on_network_error(self, mock_init):
|
||||
"""URLError (network down) is an expected failure → None."""
|
||||
from gateway.platforms.feishu import qr_register
|
||||
from plugins.platforms.feishu.adapter import qr_register
|
||||
from urllib.error import URLError
|
||||
|
||||
mock_init.side_effect = URLError("DNS resolution failed")
|
||||
result = qr_register()
|
||||
assert result is None
|
||||
|
||||
@patch("gateway.platforms.feishu._init_registration")
|
||||
@patch("plugins.platforms.feishu.adapter._init_registration")
|
||||
def test_qr_register_returns_none_on_json_error(self, mock_init):
|
||||
"""Malformed server response is an expected failure → None."""
|
||||
from gateway.platforms.feishu import qr_register
|
||||
from plugins.platforms.feishu.adapter import qr_register
|
||||
|
||||
mock_init.side_effect = json.JSONDecodeError("bad json", "", 0)
|
||||
result = qr_register()
|
||||
assert result is None
|
||||
|
||||
@patch("gateway.platforms.feishu._init_registration")
|
||||
@patch("plugins.platforms.feishu.adapter._init_registration")
|
||||
def test_qr_register_propagates_unexpected_errors(self, mock_init):
|
||||
"""Bugs (e.g. AttributeError) must not be swallowed — they propagate."""
|
||||
from gateway.platforms.feishu import qr_register
|
||||
from plugins.platforms.feishu.adapter import qr_register
|
||||
|
||||
mock_init.side_effect = AttributeError("some internal bug")
|
||||
with pytest.raises(AttributeError, match="some internal bug"):
|
||||
|
|
@ -411,29 +411,29 @@ class TestQrRegister:
|
|||
|
||||
# -- Negative paths: partial/malformed server responses --
|
||||
|
||||
@patch("gateway.platforms.feishu._render_qr")
|
||||
@patch("gateway.platforms.feishu._begin_registration")
|
||||
@patch("gateway.platforms.feishu._init_registration")
|
||||
@patch("plugins.platforms.feishu.adapter._render_qr")
|
||||
@patch("plugins.platforms.feishu.adapter._begin_registration")
|
||||
@patch("plugins.platforms.feishu.adapter._init_registration")
|
||||
def test_qr_register_returns_none_when_begin_missing_device_code(
|
||||
self, mock_init, mock_begin, mock_render
|
||||
):
|
||||
"""Server returns begin response without device_code → RuntimeError → None."""
|
||||
from gateway.platforms.feishu import qr_register
|
||||
from plugins.platforms.feishu.adapter import qr_register
|
||||
|
||||
mock_begin.side_effect = RuntimeError("Feishu registration did not return a device_code")
|
||||
result = qr_register()
|
||||
assert result is None
|
||||
|
||||
@patch("gateway.platforms.feishu.probe_bot")
|
||||
@patch("gateway.platforms.feishu._render_qr")
|
||||
@patch("gateway.platforms.feishu._poll_registration")
|
||||
@patch("gateway.platforms.feishu._begin_registration")
|
||||
@patch("gateway.platforms.feishu._init_registration")
|
||||
@patch("plugins.platforms.feishu.adapter.probe_bot")
|
||||
@patch("plugins.platforms.feishu.adapter._render_qr")
|
||||
@patch("plugins.platforms.feishu.adapter._poll_registration")
|
||||
@patch("plugins.platforms.feishu.adapter._begin_registration")
|
||||
@patch("plugins.platforms.feishu.adapter._init_registration")
|
||||
def test_qr_register_succeeds_even_when_probe_fails(
|
||||
self, mock_init, mock_begin, mock_poll, mock_render, mock_probe
|
||||
):
|
||||
"""Registration succeeds but probe fails → result with bot_name=None."""
|
||||
from gateway.platforms.feishu import qr_register
|
||||
from plugins.platforms.feishu.adapter import qr_register
|
||||
|
||||
mock_begin.return_value = {
|
||||
"device_code": "dc_123",
|
||||
|
|
|
|||
|
|
@ -365,7 +365,7 @@ class TestMatrixConfigLoading:
|
|||
|
||||
def _make_adapter():
|
||||
"""Create a MatrixAdapter with mocked config."""
|
||||
from gateway.platforms.matrix import MatrixAdapter
|
||||
from plugins.platforms.matrix.adapter import MatrixAdapter
|
||||
config = PlatformConfig(
|
||||
enabled=True,
|
||||
token="syt_test_token",
|
||||
|
|
@ -391,7 +391,7 @@ class TestMatrixTypingIndicator:
|
|||
@pytest.mark.asyncio
|
||||
async def test_stop_typing_clears_matrix_typing_state(self):
|
||||
"""stop_typing() should send typing=false instead of waiting for timeout expiry."""
|
||||
from gateway.platforms.matrix import RoomID
|
||||
from plugins.platforms.matrix.adapter import RoomID
|
||||
|
||||
await self.adapter.stop_typing("!room:example.org")
|
||||
|
||||
|
|
@ -712,7 +712,7 @@ class TestMatrixBangCommandAlias:
|
|||
return captured_event
|
||||
|
||||
def test_known_bang_command_normalizes_to_slash_command(self):
|
||||
from gateway.platforms.matrix import _normalize_matrix_bang_command
|
||||
from plugins.platforms.matrix.adapter import _normalize_matrix_bang_command
|
||||
|
||||
assert _normalize_matrix_bang_command("!model") == "/model"
|
||||
assert (
|
||||
|
|
@ -726,7 +726,7 @@ class TestMatrixBangCommandAlias:
|
|||
assert _normalize_matrix_bang_command("!tasks") == "/tasks"
|
||||
|
||||
def test_unknown_bang_text_is_not_treated_as_command(self):
|
||||
from gateway.platforms.matrix import _normalize_matrix_bang_command
|
||||
from plugins.platforms.matrix.adapter import _normalize_matrix_bang_command
|
||||
|
||||
assert _normalize_matrix_bang_command("!important note") == "!important note"
|
||||
assert _normalize_matrix_bang_command("! wow") == "! wow"
|
||||
|
|
@ -786,7 +786,7 @@ class TestMatrixBangCommandAlias:
|
|||
def test_bang_alias_underscore_resolves_to_hyphen_form(self):
|
||||
"""!set_home must emit a dispatchable token even though set_home is
|
||||
not itself registered — the hyphenated alias set-home is."""
|
||||
from gateway.platforms.matrix import _normalize_matrix_bang_command
|
||||
from plugins.platforms.matrix.adapter import _normalize_matrix_bang_command
|
||||
|
||||
# set_home (underscore) is NOT a registered command/alias, but
|
||||
# set-home (hyphen) is. The normalizer must emit the resolvable form.
|
||||
|
|
@ -806,7 +806,7 @@ class TestMatrixBangCommandAlias:
|
|||
with patch.object(
|
||||
skill_commands_mod, "get_skill_commands", return_value=fake_skills
|
||||
):
|
||||
from gateway.platforms.matrix import _normalize_matrix_bang_command
|
||||
from plugins.platforms.matrix.adapter import _normalize_matrix_bang_command
|
||||
|
||||
# is_gateway_known_command won't know these; the skill branch must.
|
||||
assert _normalize_matrix_bang_command("!arxiv") == "/arxiv"
|
||||
|
|
@ -1077,7 +1077,7 @@ class TestMatrixMarkdownToHtml:
|
|||
assert "blob:" not in result.lower()
|
||||
|
||||
def test_matrix_markdown_rejects_obfuscated_javascript_links(self):
|
||||
from gateway.platforms.matrix import _sanitize_matrix_html
|
||||
from plugins.platforms.matrix.adapter import _sanitize_matrix_html
|
||||
|
||||
result = _sanitize_matrix_html('<a href="java\nscript:alert(1)">click</a>')
|
||||
assert "javascript:" not in result.lower()
|
||||
|
|
@ -1160,7 +1160,7 @@ class TestMatrixDisplayName:
|
|||
|
||||
class TestMatrixModuleImport:
|
||||
def test_module_importable_without_mautrix(self):
|
||||
"""gateway.platforms.matrix must be importable even when mautrix is
|
||||
"""plugins.platforms.matrix.adapter must be importable even when mautrix is
|
||||
not installed — otherwise the gateway crashes for ALL platforms.
|
||||
|
||||
This test uses a subprocess to avoid polluting the current process's
|
||||
|
|
@ -1182,7 +1182,7 @@ class TestMatrixModuleImport:
|
|||
"for k in list(sys.modules):\n"
|
||||
" if k.startswith('mautrix'): del sys.modules[k]\n"
|
||||
"from unittest.mock import patch\n"
|
||||
"from gateway.platforms.matrix import check_matrix_requirements\n"
|
||||
"from plugins.platforms.matrix.adapter import check_matrix_requirements\n"
|
||||
"with patch('tools.lazy_deps.ensure', side_effect=ImportError('blocked')):\n"
|
||||
" assert not check_matrix_requirements()\n"
|
||||
"print('OK')\n"
|
||||
|
|
@ -1199,7 +1199,7 @@ class TestMatrixRequirements:
|
|||
monkeypatch.setenv("MATRIX_ACCESS_TOKEN", "syt_test")
|
||||
monkeypatch.setenv("MATRIX_HOMESERVER", "https://matrix.example.org")
|
||||
monkeypatch.delenv("MATRIX_ENCRYPTION", raising=False)
|
||||
from gateway.platforms.matrix import check_matrix_requirements
|
||||
from plugins.platforms.matrix.adapter import check_matrix_requirements
|
||||
with patch("tools.lazy_deps.feature_missing", return_value=()):
|
||||
assert check_matrix_requirements() is True
|
||||
|
||||
|
|
@ -1207,13 +1207,13 @@ class TestMatrixRequirements:
|
|||
monkeypatch.delenv("MATRIX_ACCESS_TOKEN", raising=False)
|
||||
monkeypatch.delenv("MATRIX_PASSWORD", raising=False)
|
||||
monkeypatch.delenv("MATRIX_HOMESERVER", raising=False)
|
||||
from gateway.platforms.matrix import check_matrix_requirements
|
||||
from plugins.platforms.matrix.adapter import check_matrix_requirements
|
||||
assert check_matrix_requirements() is False
|
||||
|
||||
def test_check_requirements_without_homeserver(self, monkeypatch):
|
||||
monkeypatch.setenv("MATRIX_ACCESS_TOKEN", "syt_test")
|
||||
monkeypatch.delenv("MATRIX_HOMESERVER", raising=False)
|
||||
from gateway.platforms.matrix import check_matrix_requirements
|
||||
from plugins.platforms.matrix.adapter import check_matrix_requirements
|
||||
assert check_matrix_requirements() is False
|
||||
|
||||
def test_check_requirements_encryption_true_no_e2ee_deps(self, monkeypatch):
|
||||
|
|
@ -1222,7 +1222,7 @@ class TestMatrixRequirements:
|
|||
monkeypatch.setenv("MATRIX_HOMESERVER", "https://matrix.example.org")
|
||||
monkeypatch.setenv("MATRIX_ENCRYPTION", "true")
|
||||
|
||||
from gateway.platforms import matrix as matrix_mod
|
||||
import plugins.platforms.matrix.adapter as matrix_mod
|
||||
with patch.object(matrix_mod, "_check_e2ee_deps", return_value=False), \
|
||||
patch("tools.lazy_deps.feature_missing", return_value=()):
|
||||
assert matrix_mod.check_matrix_requirements() is False
|
||||
|
|
@ -1234,7 +1234,7 @@ class TestMatrixRequirements:
|
|||
monkeypatch.setenv("MATRIX_E2EE_MODE", "optional")
|
||||
monkeypatch.delenv("MATRIX_ENCRYPTION", raising=False)
|
||||
|
||||
from gateway.platforms import matrix as matrix_mod
|
||||
import plugins.platforms.matrix.adapter as matrix_mod
|
||||
with patch.object(matrix_mod, "_check_e2ee_deps", return_value=False), \
|
||||
patch("tools.lazy_deps.feature_missing", return_value=()), \
|
||||
patch("tools.lazy_deps.ensure_and_bind", return_value=True):
|
||||
|
|
@ -1246,7 +1246,7 @@ class TestMatrixRequirements:
|
|||
monkeypatch.setenv("MATRIX_HOMESERVER", "https://matrix.example.org")
|
||||
monkeypatch.delenv("MATRIX_ENCRYPTION", raising=False)
|
||||
|
||||
from gateway.platforms import matrix as matrix_mod
|
||||
import plugins.platforms.matrix.adapter as matrix_mod
|
||||
with patch.object(matrix_mod, "_check_e2ee_deps", return_value=False), \
|
||||
patch("tools.lazy_deps.feature_missing", return_value=()):
|
||||
assert matrix_mod.check_matrix_requirements() is True
|
||||
|
|
@ -1257,7 +1257,7 @@ class TestMatrixRequirements:
|
|||
monkeypatch.setenv("MATRIX_HOMESERVER", "https://matrix.example.org")
|
||||
monkeypatch.setenv("MATRIX_ENCRYPTION", "true")
|
||||
|
||||
from gateway.platforms import matrix as matrix_mod
|
||||
import plugins.platforms.matrix.adapter as matrix_mod
|
||||
with patch.object(matrix_mod, "_check_e2ee_deps", return_value=True), \
|
||||
patch("tools.lazy_deps.feature_missing", return_value=()):
|
||||
assert matrix_mod.check_matrix_requirements() is True
|
||||
|
|
@ -1272,7 +1272,7 @@ class TestMatrixRequirements:
|
|||
a confusing ``No module named 'asyncpg'`` deep in
|
||||
``MatrixAdapter.connect()``.
|
||||
"""
|
||||
from gateway.platforms.matrix import _check_e2ee_deps
|
||||
from plugins.platforms.matrix.adapter import _check_e2ee_deps
|
||||
import builtins
|
||||
real_import = builtins.__import__
|
||||
|
||||
|
|
@ -1290,7 +1290,7 @@ class TestMatrixRequirements:
|
|||
Mautrix's ``Database.create("sqlite:///...")`` driver lookup imports
|
||||
aiosqlite lazily — without it, connect fails at ``crypto_db.start()``.
|
||||
"""
|
||||
from gateway.platforms.matrix import _check_e2ee_deps
|
||||
from plugins.platforms.matrix.adapter import _check_e2ee_deps
|
||||
import builtins
|
||||
real_import = builtins.__import__
|
||||
|
||||
|
|
@ -1314,7 +1314,7 @@ class TestMatrixRequirements:
|
|||
monkeypatch.setenv("MATRIX_HOMESERVER", "https://matrix.example.org")
|
||||
monkeypatch.delenv("MATRIX_ENCRYPTION", raising=False)
|
||||
|
||||
from gateway.platforms import matrix as matrix_mod
|
||||
import plugins.platforms.matrix.adapter as matrix_mod
|
||||
|
||||
# Simulate "mautrix installed, asyncpg missing" → feature_missing
|
||||
# returns a non-empty tuple → ensure_and_bind MUST be called.
|
||||
|
|
@ -1344,7 +1344,7 @@ class TestMatrixAccessTokenAuth:
|
|||
@pytest.mark.asyncio
|
||||
async def test_connect_with_access_token_and_encryption(self):
|
||||
"""connect() should call whoami, set user_id/device_id, set up crypto."""
|
||||
from gateway.platforms.matrix import MatrixAdapter
|
||||
from plugins.platforms.matrix.adapter import MatrixAdapter
|
||||
|
||||
config = PlatformConfig(
|
||||
enabled=True,
|
||||
|
|
@ -1398,7 +1398,7 @@ class TestMatrixAccessTokenAuth:
|
|||
fake_mautrix_mods["mautrix.client"].Client = MagicMock(return_value=mock_client)
|
||||
fake_mautrix_mods["mautrix.crypto"].OlmMachine = MagicMock(return_value=mock_olm)
|
||||
|
||||
from gateway.platforms import matrix as matrix_mod
|
||||
import plugins.platforms.matrix.adapter as matrix_mod
|
||||
with patch.object(matrix_mod, "_check_e2ee_deps", return_value=True):
|
||||
with patch.dict("sys.modules", fake_mautrix_mods):
|
||||
with patch.object(adapter, "_refresh_dm_cache", AsyncMock()):
|
||||
|
|
@ -1450,7 +1450,7 @@ class TestMatrixE2EEHardFail:
|
|||
|
||||
@pytest.mark.asyncio
|
||||
async def test_connect_fails_when_encryption_true_but_no_e2ee_deps(self):
|
||||
from gateway.platforms.matrix import MatrixAdapter
|
||||
from plugins.platforms.matrix.adapter import MatrixAdapter
|
||||
|
||||
config = PlatformConfig(
|
||||
enabled=True,
|
||||
|
|
@ -1477,7 +1477,7 @@ class TestMatrixE2EEHardFail:
|
|||
|
||||
fake_mautrix_mods["mautrix.client"].Client = MagicMock(return_value=mock_client)
|
||||
|
||||
from gateway.platforms import matrix as matrix_mod
|
||||
import plugins.platforms.matrix.adapter as matrix_mod
|
||||
with patch.object(matrix_mod, "_check_e2ee_deps", return_value=False):
|
||||
with patch.dict("sys.modules", fake_mautrix_mods):
|
||||
with patch.object(adapter, "_sync_loop", AsyncMock(return_value=None)):
|
||||
|
|
@ -1487,7 +1487,7 @@ class TestMatrixE2EEHardFail:
|
|||
|
||||
@pytest.mark.asyncio
|
||||
async def test_connect_continues_when_e2ee_optional_but_no_deps(self):
|
||||
from gateway.platforms.matrix import MatrixAdapter
|
||||
from plugins.platforms.matrix.adapter import MatrixAdapter
|
||||
|
||||
config = PlatformConfig(
|
||||
enabled=True,
|
||||
|
|
@ -1524,7 +1524,7 @@ class TestMatrixE2EEHardFail:
|
|||
|
||||
fake_mautrix_mods["mautrix.client"].Client = MagicMock(return_value=mock_client)
|
||||
|
||||
from gateway.platforms import matrix as matrix_mod
|
||||
import plugins.platforms.matrix.adapter as matrix_mod
|
||||
with patch.object(matrix_mod, "_check_e2ee_deps", return_value=False):
|
||||
with patch.dict("sys.modules", fake_mautrix_mods):
|
||||
with patch.object(matrix_mod, "_create_matrix_session", return_value=MagicMock()):
|
||||
|
|
@ -1538,7 +1538,7 @@ class TestMatrixE2EEHardFail:
|
|||
@pytest.mark.asyncio
|
||||
async def test_connect_fails_when_crypto_setup_raises(self):
|
||||
"""Even if _check_e2ee_deps passes, if OlmMachine raises, hard-fail."""
|
||||
from gateway.platforms.matrix import MatrixAdapter
|
||||
from plugins.platforms.matrix.adapter import MatrixAdapter
|
||||
|
||||
config = PlatformConfig(
|
||||
enabled=True,
|
||||
|
|
@ -1566,7 +1566,7 @@ class TestMatrixE2EEHardFail:
|
|||
fake_mautrix_mods["mautrix.client"].Client = MagicMock(return_value=mock_client)
|
||||
fake_mautrix_mods["mautrix.crypto"].OlmMachine = MagicMock(side_effect=Exception("olm init failed"))
|
||||
|
||||
from gateway.platforms import matrix as matrix_mod
|
||||
import plugins.platforms.matrix.adapter as matrix_mod
|
||||
with patch.object(matrix_mod, "_check_e2ee_deps", return_value=True):
|
||||
with patch.dict("sys.modules", fake_mautrix_mods):
|
||||
result = await adapter.connect()
|
||||
|
|
@ -1578,7 +1578,7 @@ class TestMatrixDeviceId:
|
|||
"""MATRIX_DEVICE_ID should be used for stable device identity."""
|
||||
|
||||
def test_device_id_from_config_extra(self):
|
||||
from gateway.platforms.matrix import MatrixAdapter
|
||||
from plugins.platforms.matrix.adapter import MatrixAdapter
|
||||
|
||||
config = PlatformConfig(
|
||||
enabled=True,
|
||||
|
|
@ -1594,7 +1594,7 @@ class TestMatrixDeviceId:
|
|||
def test_device_id_from_env(self, monkeypatch):
|
||||
monkeypatch.setenv("MATRIX_DEVICE_ID", "FROM_ENV")
|
||||
|
||||
from gateway.platforms.matrix import MatrixAdapter
|
||||
from plugins.platforms.matrix.adapter import MatrixAdapter
|
||||
|
||||
config = PlatformConfig(
|
||||
enabled=True,
|
||||
|
|
@ -1609,7 +1609,7 @@ class TestMatrixDeviceId:
|
|||
def test_device_id_config_takes_precedence_over_env(self, monkeypatch):
|
||||
monkeypatch.setenv("MATRIX_DEVICE_ID", "FROM_ENV")
|
||||
|
||||
from gateway.platforms.matrix import MatrixAdapter
|
||||
from plugins.platforms.matrix.adapter import MatrixAdapter
|
||||
|
||||
config = PlatformConfig(
|
||||
enabled=True,
|
||||
|
|
@ -1625,7 +1625,7 @@ class TestMatrixDeviceId:
|
|||
@pytest.mark.asyncio
|
||||
async def test_connect_uses_configured_device_id_over_whoami(self):
|
||||
"""When MATRIX_DEVICE_ID is set, it should be used instead of whoami device_id."""
|
||||
from gateway.platforms.matrix import MatrixAdapter
|
||||
from plugins.platforms.matrix.adapter import MatrixAdapter
|
||||
|
||||
config = PlatformConfig(
|
||||
enabled=True,
|
||||
|
|
@ -1672,7 +1672,7 @@ class TestMatrixDeviceId:
|
|||
fake_mautrix_mods["mautrix.client"].Client = MagicMock(return_value=mock_client)
|
||||
fake_mautrix_mods["mautrix.crypto"].OlmMachine = MagicMock(return_value=mock_olm)
|
||||
|
||||
from gateway.platforms import matrix as matrix_mod
|
||||
import plugins.platforms.matrix.adapter as matrix_mod
|
||||
with patch.object(matrix_mod, "_check_e2ee_deps", return_value=True):
|
||||
with patch.dict("sys.modules", fake_mautrix_mods):
|
||||
with patch.object(adapter, "_refresh_dm_cache", AsyncMock()):
|
||||
|
|
@ -1691,7 +1691,7 @@ class TestMatrixPasswordLoginDeviceId:
|
|||
|
||||
@pytest.mark.asyncio
|
||||
async def test_password_login_uses_device_id(self):
|
||||
from gateway.platforms.matrix import MatrixAdapter
|
||||
from plugins.platforms.matrix.adapter import MatrixAdapter
|
||||
|
||||
config = PlatformConfig(
|
||||
enabled=True,
|
||||
|
|
@ -1905,7 +1905,7 @@ class TestMatrixSyncLoop:
|
|||
@pytest.mark.asyncio
|
||||
async def test_connect_receives_dm_from_initial_sync_dispatch(self):
|
||||
"""A DM delivered by initial sync should reach the message handler after connect."""
|
||||
from gateway.platforms.matrix import MatrixAdapter
|
||||
from plugins.platforms.matrix.adapter import MatrixAdapter
|
||||
|
||||
adapter = MatrixAdapter(
|
||||
PlatformConfig(
|
||||
|
|
@ -1972,7 +1972,7 @@ class TestMatrixSyncLoop:
|
|||
mock_client.handle_sync = MagicMock(side_effect=handle_sync)
|
||||
fake_mautrix_mods["mautrix.client"].Client = MagicMock(return_value=mock_client)
|
||||
|
||||
from gateway.platforms import matrix as matrix_mod
|
||||
import plugins.platforms.matrix.adapter as matrix_mod
|
||||
with patch.dict("sys.modules", fake_mautrix_mods):
|
||||
with patch.object(matrix_mod, "_create_matrix_session", return_value=MagicMock()):
|
||||
with patch.object(adapter, "_sync_loop", AsyncMock(return_value=None)):
|
||||
|
|
@ -2220,7 +2220,7 @@ class TestMatrixUploadAndSend:
|
|||
|
||||
class TestMatrixDiagnostics:
|
||||
def test_diagnostics_redacts_credentials_and_reports_status(self, monkeypatch):
|
||||
from gateway.platforms import matrix as matrix_mod
|
||||
import plugins.platforms.matrix.adapter as matrix_mod
|
||||
|
||||
monkeypatch.setenv("MATRIX_RECOVERY_KEY", "secret recovery key")
|
||||
adapter = _make_adapter()
|
||||
|
|
@ -2248,7 +2248,7 @@ class TestMatrixDiagnostics:
|
|||
assert diagnostics["media"]["max_media_bytes"] == 123
|
||||
|
||||
def test_matrix_recovery_key_is_never_logged(self, caplog, monkeypatch):
|
||||
from gateway.platforms.matrix import _handle_generated_matrix_recovery_key
|
||||
from plugins.platforms.matrix.adapter import _handle_generated_matrix_recovery_key
|
||||
|
||||
secret = "super-secret-generated-recovery-key"
|
||||
monkeypatch.delenv("MATRIX_RECOVERY_KEY_OUTPUT_FILE", raising=False)
|
||||
|
|
@ -2259,7 +2259,7 @@ class TestMatrixDiagnostics:
|
|||
assert "will not be logged" in caplog.text
|
||||
|
||||
def test_matrix_recovery_key_output_file_is_0600(self, tmp_path, monkeypatch, caplog):
|
||||
from gateway.platforms.matrix import _handle_generated_matrix_recovery_key
|
||||
from plugins.platforms.matrix.adapter import _handle_generated_matrix_recovery_key
|
||||
|
||||
secret = "super-secret-generated-recovery-key"
|
||||
output_path = tmp_path / "matrix-recovery-key.txt"
|
||||
|
|
@ -2277,7 +2277,7 @@ class TestMatrixDiagnostics:
|
|||
monkeypatch,
|
||||
caplog,
|
||||
):
|
||||
from gateway.platforms.matrix import MatrixAdapter
|
||||
from plugins.platforms.matrix.adapter import MatrixAdapter
|
||||
|
||||
monkeypatch.delenv("MATRIX_RECOVERY_KEY", raising=False)
|
||||
monkeypatch.delenv("MATRIX_RECOVERY_KEY_OUTPUT_FILE", raising=False)
|
||||
|
|
@ -2327,7 +2327,7 @@ class TestMatrixDiagnostics:
|
|||
fake_mautrix_mods["mautrix.client"].Client = MagicMock(return_value=mock_client)
|
||||
fake_mautrix_mods["mautrix.crypto"].OlmMachine = MagicMock(return_value=mock_olm)
|
||||
|
||||
from gateway.platforms import matrix as matrix_mod
|
||||
import plugins.platforms.matrix.adapter as matrix_mod
|
||||
with patch.object(matrix_mod, "_check_e2ee_deps", return_value=True):
|
||||
with patch.dict("sys.modules", fake_mautrix_mods):
|
||||
with patch.object(adapter, "_refresh_dm_cache", AsyncMock()):
|
||||
|
|
@ -2346,7 +2346,7 @@ class TestMatrixDiagnostics:
|
|||
monkeypatch,
|
||||
caplog,
|
||||
):
|
||||
from gateway.platforms.matrix import MatrixAdapter
|
||||
from plugins.platforms.matrix.adapter import MatrixAdapter
|
||||
|
||||
output_path = tmp_path / "matrix-recovery-key.txt"
|
||||
output_path.write_text("existing\n")
|
||||
|
|
@ -2398,7 +2398,7 @@ class TestMatrixDiagnostics:
|
|||
fake_mautrix_mods["mautrix.client"].Client = MagicMock(return_value=mock_client)
|
||||
fake_mautrix_mods["mautrix.crypto"].OlmMachine = MagicMock(return_value=mock_olm)
|
||||
|
||||
from gateway.platforms import matrix as matrix_mod
|
||||
import plugins.platforms.matrix.adapter as matrix_mod
|
||||
with patch.object(matrix_mod, "_check_e2ee_deps", return_value=True):
|
||||
with patch.dict("sys.modules", fake_mautrix_mods):
|
||||
with patch.object(adapter, "_refresh_dm_cache", AsyncMock()):
|
||||
|
|
@ -2421,7 +2421,7 @@ class TestMatrixDiagnostics:
|
|||
assert "diagnostic-secret-recovery-key" not in str(diagnostics)
|
||||
|
||||
def test_capability_matrix_is_declared_for_docs(self):
|
||||
from gateway.platforms.matrix import get_matrix_capabilities
|
||||
from plugins.platforms.matrix.adapter import get_matrix_capabilities
|
||||
|
||||
capabilities = get_matrix_capabilities()
|
||||
|
||||
|
|
@ -2442,7 +2442,7 @@ class TestMatrixDiagnostics:
|
|||
}
|
||||
|
||||
def test_matrix_capability_claims_match_adapter_surfaces(self):
|
||||
from gateway.platforms.matrix import MatrixAdapter, get_matrix_capabilities
|
||||
from plugins.platforms.matrix.adapter import MatrixAdapter, get_matrix_capabilities
|
||||
|
||||
capabilities = get_matrix_capabilities()
|
||||
required_methods = {
|
||||
|
|
@ -2468,7 +2468,7 @@ class TestMatrixDiagnostics:
|
|||
def test_matrix_docs_capability_table_matches_declaration(self):
|
||||
from pathlib import Path
|
||||
|
||||
from gateway.platforms.matrix import get_matrix_capabilities
|
||||
from plugins.platforms.matrix.adapter import get_matrix_capabilities
|
||||
|
||||
docs = (
|
||||
Path(__file__).resolve().parents[2]
|
||||
|
|
@ -2515,7 +2515,7 @@ class TestMatrixEncryptedSendFallback:
|
|||
class TestJoinedRoomsReference:
|
||||
def test_joined_rooms_reference_preserved_after_reassignment(self):
|
||||
"""_CryptoStateStore must see updates after initial sync populates rooms."""
|
||||
from gateway.platforms.matrix import _CryptoStateStore
|
||||
from plugins.platforms.matrix.adapter import _CryptoStateStore
|
||||
|
||||
joined = set()
|
||||
store = _CryptoStateStore(MagicMock(), joined)
|
||||
|
|
@ -2536,7 +2536,7 @@ class TestJoinedRoomsReference:
|
|||
class TestMatrixEncryptedEventHandler:
|
||||
@pytest.mark.asyncio
|
||||
async def test_connect_registers_encrypted_event_handler_when_encryption_on(self):
|
||||
from gateway.platforms.matrix import MatrixAdapter
|
||||
from plugins.platforms.matrix.adapter import MatrixAdapter
|
||||
|
||||
config = PlatformConfig(
|
||||
enabled=True,
|
||||
|
|
@ -2582,7 +2582,7 @@ class TestMatrixEncryptedEventHandler:
|
|||
fake_mautrix_mods["mautrix.client"].Client = MagicMock(return_value=mock_client)
|
||||
fake_mautrix_mods["mautrix.crypto"].OlmMachine = MagicMock(return_value=mock_olm)
|
||||
|
||||
from gateway.platforms import matrix as matrix_mod
|
||||
import plugins.platforms.matrix.adapter as matrix_mod
|
||||
with patch.object(matrix_mod, "_check_e2ee_deps", return_value=True):
|
||||
with patch.dict("sys.modules", fake_mautrix_mods):
|
||||
with patch.object(adapter, "_refresh_dm_cache", AsyncMock()):
|
||||
|
|
@ -2602,7 +2602,7 @@ class TestMatrixEncryptedEventHandler:
|
|||
@pytest.mark.asyncio
|
||||
async def test_connect_fails_on_stale_otk_conflict(self):
|
||||
"""connect() must refuse E2EE when OTK upload hits 'already exists'."""
|
||||
from gateway.platforms.matrix import MatrixAdapter
|
||||
from plugins.platforms.matrix.adapter import MatrixAdapter
|
||||
|
||||
config = PlatformConfig(
|
||||
enabled=True,
|
||||
|
|
@ -2651,7 +2651,7 @@ class TestMatrixEncryptedEventHandler:
|
|||
fake_mautrix_mods["mautrix.client"].Client = MagicMock(return_value=mock_client)
|
||||
fake_mautrix_mods["mautrix.crypto"].OlmMachine = MagicMock(return_value=mock_olm)
|
||||
|
||||
from gateway.platforms import matrix as matrix_mod
|
||||
import plugins.platforms.matrix.adapter as matrix_mod
|
||||
with patch.object(matrix_mod, "_check_e2ee_deps", return_value=True):
|
||||
with patch.dict("sys.modules", fake_mautrix_mods):
|
||||
result = await adapter.connect()
|
||||
|
|
@ -2724,7 +2724,7 @@ class TestMatrixMarkdownHtmlSecurity:
|
|||
"""Tests for HTML injection prevention in _markdown_to_html_fallback."""
|
||||
|
||||
def setup_method(self):
|
||||
from gateway.platforms.matrix import MatrixAdapter
|
||||
from plugins.platforms.matrix.adapter import MatrixAdapter
|
||||
self.convert = MatrixAdapter._markdown_to_html_fallback
|
||||
|
||||
def test_script_injection_in_header(self):
|
||||
|
|
@ -2785,7 +2785,7 @@ class TestMatrixMarkdownHtmlFormatting:
|
|||
"""Tests for new formatting capabilities in _markdown_to_html_fallback."""
|
||||
|
||||
def setup_method(self):
|
||||
from gateway.platforms.matrix import MatrixAdapter
|
||||
from plugins.platforms.matrix.adapter import MatrixAdapter
|
||||
self.convert = MatrixAdapter._markdown_to_html_fallback
|
||||
|
||||
def test_fenced_code_block(self):
|
||||
|
|
@ -2852,23 +2852,23 @@ class TestMatrixMarkdownHtmlFormatting:
|
|||
|
||||
class TestMatrixLinkSanitization:
|
||||
def test_safe_https_url(self):
|
||||
from gateway.platforms.matrix import MatrixAdapter
|
||||
from plugins.platforms.matrix.adapter import MatrixAdapter
|
||||
assert MatrixAdapter._sanitize_link_url("https://example.com") == "https://example.com"
|
||||
|
||||
def test_javascript_blocked(self):
|
||||
from gateway.platforms.matrix import MatrixAdapter
|
||||
from plugins.platforms.matrix.adapter import MatrixAdapter
|
||||
assert MatrixAdapter._sanitize_link_url("javascript:alert(1)") == ""
|
||||
|
||||
def test_data_blocked(self):
|
||||
from gateway.platforms.matrix import MatrixAdapter
|
||||
from plugins.platforms.matrix.adapter import MatrixAdapter
|
||||
assert MatrixAdapter._sanitize_link_url("data:text/html,bad") == ""
|
||||
|
||||
def test_vbscript_blocked(self):
|
||||
from gateway.platforms.matrix import MatrixAdapter
|
||||
from plugins.platforms.matrix.adapter import MatrixAdapter
|
||||
assert MatrixAdapter._sanitize_link_url("vbscript:bad") == ""
|
||||
|
||||
def test_quotes_escaped(self):
|
||||
from gateway.platforms.matrix import MatrixAdapter
|
||||
from plugins.platforms.matrix.adapter import MatrixAdapter
|
||||
result = MatrixAdapter._sanitize_link_url('http://x"y')
|
||||
assert '"' not in result
|
||||
assert """ in result
|
||||
|
|
@ -3906,7 +3906,7 @@ class TestMatrixRequireMention:
|
|||
"""require_mention should honor config.extra like thread_require_mention."""
|
||||
|
||||
def test_require_mention_from_config_extra_false(self):
|
||||
from gateway.platforms.matrix import MatrixAdapter
|
||||
from plugins.platforms.matrix.adapter import MatrixAdapter
|
||||
|
||||
config = PlatformConfig(
|
||||
enabled=True,
|
||||
|
|
@ -3922,7 +3922,7 @@ class TestMatrixRequireMention:
|
|||
def test_require_mention_from_env_when_extra_unset(self, monkeypatch):
|
||||
monkeypatch.setenv("MATRIX_REQUIRE_MENTION", "false")
|
||||
|
||||
from gateway.platforms.matrix import MatrixAdapter
|
||||
from plugins.platforms.matrix.adapter import MatrixAdapter
|
||||
|
||||
config = PlatformConfig(
|
||||
enabled=True,
|
||||
|
|
@ -3935,7 +3935,7 @@ class TestMatrixRequireMention:
|
|||
def test_require_mention_config_takes_precedence_over_env(self, monkeypatch):
|
||||
monkeypatch.setenv("MATRIX_REQUIRE_MENTION", "true")
|
||||
|
||||
from gateway.platforms.matrix import MatrixAdapter
|
||||
from plugins.platforms.matrix.adapter import MatrixAdapter
|
||||
|
||||
config = PlatformConfig(
|
||||
enabled=True,
|
||||
|
|
@ -3950,7 +3950,7 @@ class TestMatrixRequireMention:
|
|||
|
||||
@pytest.mark.asyncio
|
||||
async def test_require_mention_false_allows_unmentioned_group_message(self):
|
||||
from gateway.platforms.matrix import MatrixAdapter
|
||||
from plugins.platforms.matrix.adapter import MatrixAdapter
|
||||
|
||||
config = PlatformConfig(
|
||||
enabled=True,
|
||||
|
|
@ -4061,7 +4061,7 @@ class TestMatrixClockSkewWarning:
|
|||
# Server events are dated 2h before startup_ts (skewed clock).
|
||||
skewed_event_ts_ms = int((self.adapter._startup_ts - 7200) * 1000)
|
||||
|
||||
with caplog.at_level(logging.WARNING, logger="gateway.platforms.matrix"):
|
||||
with caplog.at_level(logging.WARNING, logger="plugins.platforms.matrix.adapter"):
|
||||
for i in range(5):
|
||||
ev = self._mk_event(
|
||||
sender=f"@alice{i}:example.org", ts_ms=skewed_event_ts_ms
|
||||
|
|
@ -4075,7 +4075,7 @@ class TestMatrixClockSkewWarning:
|
|||
# assertion.
|
||||
skew_warnings = [
|
||||
r for r in caplog.records
|
||||
if r.name == "gateway.platforms.matrix"
|
||||
if r.name == "plugins.platforms.matrix.adapter"
|
||||
and r.levelname == "WARNING"
|
||||
and "set-ntp" in r.getMessage()
|
||||
]
|
||||
|
|
@ -4100,7 +4100,7 @@ class TestMatrixClockSkewWarning:
|
|||
self.adapter._startup_ts = now - 1
|
||||
old_ts_ms = int((self.adapter._startup_ts - 3600) * 1000)
|
||||
|
||||
with caplog.at_level(logging.WARNING, logger="gateway.platforms.matrix"):
|
||||
with caplog.at_level(logging.WARNING, logger="plugins.platforms.matrix.adapter"):
|
||||
for i in range(5):
|
||||
ev = self._mk_event(
|
||||
sender=f"@alice{i}:example.org", ts_ms=old_ts_ms
|
||||
|
|
@ -4111,7 +4111,7 @@ class TestMatrixClockSkewWarning:
|
|||
assert self.adapter._clock_skew_warned is False
|
||||
skew_warnings = [
|
||||
r for r in caplog.records
|
||||
if r.name == "gateway.platforms.matrix"
|
||||
if r.name == "plugins.platforms.matrix.adapter"
|
||||
and "set-ntp" in r.getMessage()
|
||||
]
|
||||
assert skew_warnings == []
|
||||
|
|
@ -4126,7 +4126,7 @@ class TestMatrixClockSkewWarning:
|
|||
self.adapter._startup_ts = now - 120 # extra slack vs the 30s gate
|
||||
old_ts_ms = int((self.adapter._startup_ts - 3600) * 1000)
|
||||
|
||||
with caplog.at_level(logging.WARNING, logger="gateway.platforms.matrix"):
|
||||
with caplog.at_level(logging.WARNING, logger="plugins.platforms.matrix.adapter"):
|
||||
for i in range(2): # only 2 late drops — under the threshold
|
||||
ev = self._mk_event(
|
||||
sender=f"@alice{i}:example.org", ts_ms=old_ts_ms
|
||||
|
|
@ -4152,7 +4152,7 @@ class TestMatrixClockSkewWarning:
|
|||
self.adapter._startup_ts = now - 120
|
||||
# Each event has a different age, ranging from 1h to 30d ago.
|
||||
ages_in_hours = [1, 24, 168, 720, 4] # 1h, 1d, 1w, 30d, 4h
|
||||
with caplog.at_level(logging.WARNING, logger="gateway.platforms.matrix"):
|
||||
with caplog.at_level(logging.WARNING, logger="plugins.platforms.matrix.adapter"):
|
||||
for i, hrs in enumerate(ages_in_hours):
|
||||
ts_ms = int((self.adapter._startup_ts - hrs * 3600) * 1000)
|
||||
ev = self._mk_event(
|
||||
|
|
@ -4165,7 +4165,7 @@ class TestMatrixClockSkewWarning:
|
|||
assert self.adapter._clock_skew_warned is False
|
||||
skew_warnings = [
|
||||
r for r in caplog.records
|
||||
if r.name == "gateway.platforms.matrix"
|
||||
if r.name == "plugins.platforms.matrix.adapter"
|
||||
and "set-ntp" in r.getMessage()
|
||||
]
|
||||
assert skew_warnings == []
|
||||
|
|
@ -4189,7 +4189,7 @@ class TestMatrixClockSkewWarning:
|
|||
self.adapter._startup_ts = now - 60
|
||||
skewed_ms = int((self.adapter._startup_ts - 7200) * 1000)
|
||||
|
||||
with caplog.at_level(logging.WARNING, logger="gateway.platforms.matrix"):
|
||||
with caplog.at_level(logging.WARNING, logger="plugins.platforms.matrix.adapter"):
|
||||
for i in range(3):
|
||||
ev = self._mk_event(
|
||||
sender=f"@alice{i}:example.org", ts_ms=skewed_ms,
|
||||
|
|
@ -4215,7 +4215,7 @@ class TestMatrixClockSkewWarning:
|
|||
|
||||
skew_warnings = [
|
||||
r for r in caplog.records
|
||||
if r.name == "gateway.platforms.matrix"
|
||||
if r.name == "plugins.platforms.matrix.adapter"
|
||||
and "set-ntp" in r.getMessage()
|
||||
]
|
||||
assert len(skew_warnings) == 2, (
|
||||
|
|
@ -4292,7 +4292,7 @@ class TestMatrixProxyConfig:
|
|||
for k, v in proxy_env.items():
|
||||
monkeypatch.setenv(k, v)
|
||||
with patch.dict("sys.modules", _make_fake_mautrix()):
|
||||
from gateway.platforms.matrix import MatrixAdapter
|
||||
from plugins.platforms.matrix.adapter import MatrixAdapter
|
||||
cfg = PlatformConfig(enabled=True, token="syt_test",
|
||||
extra={"homeserver": "https://matrix.example.org",
|
||||
"user_id": "@bot:example.org"})
|
||||
|
|
@ -4325,7 +4325,7 @@ class TestCreateMatrixSession:
|
|||
@pytest.mark.asyncio
|
||||
async def test_no_proxy_returns_trust_env_session(self):
|
||||
with patch.dict("sys.modules", _make_fake_mautrix()):
|
||||
from gateway.platforms.matrix import _create_matrix_session
|
||||
from plugins.platforms.matrix.adapter import _create_matrix_session
|
||||
session = _create_matrix_session(None)
|
||||
try:
|
||||
assert session.trust_env is True
|
||||
|
|
@ -4335,7 +4335,7 @@ class TestCreateMatrixSession:
|
|||
@pytest.mark.asyncio
|
||||
async def test_http_proxy_sets_default_proxy(self):
|
||||
with patch.dict("sys.modules", _make_fake_mautrix()):
|
||||
from gateway.platforms.matrix import _create_matrix_session
|
||||
from plugins.platforms.matrix.adapter import _create_matrix_session
|
||||
session = _create_matrix_session("http://proxy:8080")
|
||||
try:
|
||||
assert str(session._default_proxy) == "http://proxy:8080"
|
||||
|
|
@ -4353,7 +4353,7 @@ class TestCreateMatrixSession:
|
|||
)
|
||||
),
|
||||
}):
|
||||
from gateway.platforms.matrix import _create_matrix_session
|
||||
from plugins.platforms.matrix.adapter import _create_matrix_session
|
||||
session = _create_matrix_session("socks5://proxy:1080")
|
||||
try:
|
||||
assert session.connector is fake_connector
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ import pytest
|
|||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Stub mautrix so gateway.platforms.matrix can be imported without the SDK.
|
||||
# Stub mautrix so plugins.platforms.matrix.adapter can be imported without the SDK.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _stub_mautrix():
|
||||
|
|
@ -64,7 +64,7 @@ def _stub_mautrix():
|
|||
|
||||
_stub_mautrix()
|
||||
|
||||
from gateway.platforms.matrix import MatrixAdapter, _MatrixApprovalPrompt # noqa: E402
|
||||
from plugins.platforms.matrix.adapter import MatrixAdapter, _MatrixApprovalPrompt # noqa: E402
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ class TestMatrixExecApprovalReactions:
|
|||
@pytest.mark.asyncio
|
||||
async def test_send_exec_approval_registers_prompt_and_seeds_reactions(self, monkeypatch):
|
||||
monkeypatch.setenv("MATRIX_ALLOWED_USERS", "@liizfq:liizfq.top")
|
||||
from gateway.platforms.matrix import MatrixAdapter
|
||||
from plugins.platforms.matrix.adapter import MatrixAdapter
|
||||
|
||||
adapter = MatrixAdapter(PlatformConfig(enabled=True, token="tok", extra={"homeserver": "https://matrix.example.org"}))
|
||||
adapter._client = types.SimpleNamespace()
|
||||
|
|
@ -34,7 +34,7 @@ class TestMatrixExecApprovalReactions:
|
|||
@pytest.mark.asyncio
|
||||
async def test_reaction_resolves_pending_approval(self, monkeypatch):
|
||||
monkeypatch.setenv("MATRIX_ALLOWED_USERS", "@liizfq:liizfq.top")
|
||||
from gateway.platforms.matrix import MatrixAdapter, _MatrixApprovalPrompt
|
||||
from plugins.platforms.matrix.adapter import MatrixAdapter, _MatrixApprovalPrompt
|
||||
|
||||
adapter = MatrixAdapter(PlatformConfig(enabled=True, token="tok", extra={"homeserver": "https://matrix.example.org"}))
|
||||
# Resolve user_id so _is_self_sender doesn't defensively drop all traffic (#15763).
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ from gateway.config import PlatformConfig
|
|||
|
||||
def _make_adapter(tmp_path=None):
|
||||
"""Create a MatrixAdapter with mocked config."""
|
||||
from gateway.platforms.matrix import MatrixAdapter
|
||||
from plugins.platforms.matrix.adapter import MatrixAdapter
|
||||
|
||||
config = PlatformConfig(
|
||||
enabled=True,
|
||||
|
|
|
|||
|
|
@ -32,7 +32,7 @@ SENDER = "@alice:example.org"
|
|||
|
||||
|
||||
def _make_adapter():
|
||||
from gateway.platforms.matrix import MatrixAdapter
|
||||
from plugins.platforms.matrix.adapter import MatrixAdapter
|
||||
|
||||
adapter = MatrixAdapter(
|
||||
PlatformConfig(
|
||||
|
|
|
|||
|
|
@ -27,7 +27,7 @@ from gateway.platforms.base import MessageType
|
|||
|
||||
def _make_adapter():
|
||||
"""Create a MatrixAdapter with mocked config."""
|
||||
from gateway.platforms.matrix import MatrixAdapter
|
||||
from plugins.platforms.matrix.adapter import MatrixAdapter
|
||||
from gateway.config import PlatformConfig
|
||||
|
||||
config = PlatformConfig(
|
||||
|
|
|
|||
|
|
@ -532,10 +532,10 @@ def _ensure_slack_mock():
|
|||
|
||||
_ensure_slack_mock()
|
||||
|
||||
import gateway.platforms.slack as _slack_mod # noqa: E402
|
||||
import plugins.platforms.slack.adapter as _slack_mod # noqa: E402
|
||||
_slack_mod.SLACK_AVAILABLE = True
|
||||
|
||||
from gateway.platforms.slack import SlackAdapter # noqa: E402
|
||||
from plugins.platforms.slack.adapter import SlackAdapter # noqa: E402
|
||||
from gateway.config import PlatformConfig # noqa: E402
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -33,8 +33,8 @@ def _accepts_metadata(method) -> bool:
|
|||
@pytest.mark.parametrize(
|
||||
"module_name, class_name",
|
||||
[
|
||||
("gateway.platforms.whatsapp", "WhatsAppAdapter"),
|
||||
("gateway.platforms.email", "EmailAdapter"),
|
||||
("plugins.platforms.whatsapp.adapter", "WhatsAppAdapter"),
|
||||
("plugins.platforms.email.adapter", "EmailAdapter"),
|
||||
],
|
||||
)
|
||||
def test_send_image_accepts_metadata(module_name, class_name):
|
||||
|
|
@ -50,18 +50,18 @@ def test_send_image_accepts_metadata(module_name, class_name):
|
|||
# whose override drops metadata is a hard failure.
|
||||
_ALL_ADAPTERS = [
|
||||
("gateway.platforms.bluebubbles", "BlueBubblesAdapter"),
|
||||
("gateway.platforms.dingtalk", "DingTalkAdapter"),
|
||||
("plugins.platforms.dingtalk.adapter", "DingTalkAdapter"),
|
||||
("gateway.platforms.discord", "DiscordAdapter"),
|
||||
("gateway.platforms.email", "EmailAdapter"),
|
||||
("gateway.platforms.feishu", "FeishuAdapter"),
|
||||
("gateway.platforms.matrix", "MatrixAdapter"),
|
||||
("plugins.platforms.email.adapter", "EmailAdapter"),
|
||||
("plugins.platforms.feishu.adapter", "FeishuAdapter"),
|
||||
("plugins.platforms.matrix.adapter", "MatrixAdapter"),
|
||||
("gateway.platforms.mattermost", "MattermostAdapter"),
|
||||
("gateway.platforms.signal", "SignalAdapter"),
|
||||
("gateway.platforms.slack", "SlackAdapter"),
|
||||
("gateway.platforms.telegram", "TelegramAdapter"),
|
||||
("gateway.platforms.wecom", "WeComAdapter"),
|
||||
("plugins.platforms.slack.adapter", "SlackAdapter"),
|
||||
("plugins.platforms.telegram.adapter", "TelegramAdapter"),
|
||||
("plugins.platforms.wecom.adapter", "WeComAdapter"),
|
||||
("gateway.platforms.weixin", "WeixinAdapter"),
|
||||
("gateway.platforms.whatsapp", "WhatsAppAdapter"),
|
||||
("plugins.platforms.whatsapp.adapter", "WhatsAppAdapter"),
|
||||
("gateway.platforms.yuanbao", "YuanbaoAdapter"),
|
||||
]
|
||||
|
||||
|
|
|
|||
|
|
@ -33,9 +33,31 @@ def test_all_builtins_have_checker_or_generic_token_path():
|
|||
# Platforms with a bespoke checker
|
||||
checker_values = {p.value for p in set(_PLATFORM_CONNECTED_CHECKERS.keys())}
|
||||
|
||||
# Every built-in should be in one of the two sets
|
||||
# Platforms whose connection check now comes from a registered plugin entry
|
||||
# (is_connected / validate_config). Several adapters migrated out of core
|
||||
# into bundled plugins (#41112); their checker moved with them to the
|
||||
# platform registry, so get_connected_platforms() resolves them via the
|
||||
# registry fallback rather than _PLATFORM_CONNECTED_CHECKERS.
|
||||
plugin_checker_values: set[str] = set()
|
||||
try:
|
||||
from hermes_cli.plugins import discover_plugins
|
||||
from gateway.platform_registry import platform_registry
|
||||
discover_plugins()
|
||||
for _entry in platform_registry.all_entries():
|
||||
if _entry.is_connected is not None or _entry.validate_config is not None:
|
||||
plugin_checker_values.add(_entry.name)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Every built-in should be in one of the sets
|
||||
all_builtins = set(_BUILTIN_PLATFORM_VALUES)
|
||||
missing = all_builtins - generic_token_values - checker_values - {"local"}
|
||||
missing = (
|
||||
all_builtins
|
||||
- generic_token_values
|
||||
- checker_values
|
||||
- plugin_checker_values
|
||||
- {"local"}
|
||||
)
|
||||
|
||||
assert not missing, (
|
||||
f"Built-in platforms missing a connection checker: "
|
||||
|
|
|
|||
|
|
@ -77,11 +77,11 @@ def test_helper_is_importable_from_every_platform_that_uses_it():
|
|||
the regression shows up as a runtime adapter-startup crash."""
|
||||
# Just importing exercises the helper's import path for each adapter.
|
||||
import gateway.platforms.qqbot.adapter # noqa: F401
|
||||
import gateway.platforms.wecom # noqa: F401
|
||||
import gateway.platforms.dingtalk # noqa: F401
|
||||
import plugins.platforms.wecom.adapter # noqa: F401
|
||||
import plugins.platforms.dingtalk.adapter # noqa: F401
|
||||
import gateway.platforms.signal # noqa: F401
|
||||
import gateway.platforms.bluebubbles # noqa: F401
|
||||
import gateway.platforms.wecom_callback # noqa: F401
|
||||
import plugins.platforms.wecom.callback_adapter # noqa: F401
|
||||
|
||||
|
||||
class TestWhatsappTypingLeakFix:
|
||||
|
|
@ -98,7 +98,7 @@ class TestWhatsappTypingLeakFix:
|
|||
|
||||
def test_bare_await_removed(self):
|
||||
import inspect
|
||||
import gateway.platforms.whatsapp as mod
|
||||
import plugins.platforms.whatsapp.adapter as mod
|
||||
|
||||
src = inspect.getsource(mod.WhatsAppAdapter.send_typing)
|
||||
# The fix must be structural: the post() call is inside an
|
||||
|
|
|
|||
|
|
@ -82,7 +82,7 @@ def _ensure_telegram_mock():
|
|||
|
||||
_ensure_telegram_mock()
|
||||
|
||||
from gateway.platforms.telegram import TelegramAdapter # noqa: E402
|
||||
from plugins.platforms.telegram.adapter import TelegramAdapter # noqa: E402
|
||||
|
||||
|
||||
class TestTelegramSendImageFile:
|
||||
|
|
@ -313,7 +313,7 @@ def _ensure_slack_mock():
|
|||
|
||||
_ensure_slack_mock()
|
||||
|
||||
from gateway.platforms.slack import SlackAdapter # noqa: E402
|
||||
from plugins.platforms.slack.adapter import SlackAdapter # noqa: E402
|
||||
|
||||
|
||||
class TestSlackSendImageFile:
|
||||
|
|
|
|||
|
|
@ -115,7 +115,7 @@ def _ensure_telegram_mock():
|
|||
|
||||
_ensure_telegram_mock()
|
||||
|
||||
from gateway.platforms.telegram import TelegramAdapter # noqa: E402
|
||||
from plugins.platforms.telegram.adapter import TelegramAdapter # noqa: E402
|
||||
|
||||
|
||||
class TestTelegramMultiImage:
|
||||
|
|
@ -286,7 +286,7 @@ def _ensure_slack_mock():
|
|||
|
||||
_ensure_slack_mock()
|
||||
|
||||
from gateway.platforms.slack import SlackAdapter # noqa: E402
|
||||
from plugins.platforms.slack.adapter import SlackAdapter # noqa: E402
|
||||
|
||||
|
||||
class TestSlackMultiImage:
|
||||
|
|
@ -402,7 +402,7 @@ class TestMattermostMultiImage:
|
|||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
from gateway.platforms.email import EmailAdapter # noqa: E402
|
||||
from plugins.platforms.email.adapter import EmailAdapter # noqa: E402
|
||||
|
||||
|
||||
class TestEmailMultiImage:
|
||||
|
|
|
|||
|
|
@ -39,20 +39,20 @@ def _run_setup_feishu(
|
|||
def mock_get(name):
|
||||
return existing_env.get(name, "")
|
||||
|
||||
with patch("hermes_cli.gateway.save_env_value", side_effect=mock_save), \
|
||||
patch("hermes_cli.gateway.get_env_value", side_effect=mock_get), \
|
||||
patch("hermes_cli.gateway.prompt_yes_no", side_effect=prompt_yes_no_responses), \
|
||||
patch("hermes_cli.gateway.prompt_choice", side_effect=prompt_choice_responses), \
|
||||
patch("hermes_cli.gateway.prompt", side_effect=prompt_responses), \
|
||||
patch("hermes_cli.gateway.print_info"), \
|
||||
patch("hermes_cli.gateway.print_success"), \
|
||||
patch("hermes_cli.gateway.print_warning"), \
|
||||
patch("hermes_cli.gateway.print_error"), \
|
||||
patch("hermes_cli.gateway.color", side_effect=lambda t, c: t), \
|
||||
patch("gateway.platforms.feishu.qr_register", return_value=qr_result):
|
||||
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.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), \
|
||||
patch("hermes_cli.cli_output.print_header"), \
|
||||
patch("hermes_cli.cli_output.print_info"), \
|
||||
patch("hermes_cli.cli_output.print_success"), \
|
||||
patch("hermes_cli.cli_output.print_warning"), \
|
||||
patch("hermes_cli.cli_output.print_error"), \
|
||||
patch("plugins.platforms.feishu.adapter.qr_register", return_value=qr_result):
|
||||
|
||||
from hermes_cli.gateway import _setup_feishu
|
||||
_setup_feishu()
|
||||
from plugins.platforms.feishu.adapter import interactive_setup
|
||||
interactive_setup()
|
||||
|
||||
return saved_env
|
||||
|
||||
|
|
@ -120,7 +120,7 @@ class TestSetupFeishuConnectionMode:
|
|||
)
|
||||
assert env["FEISHU_CONNECTION_MODE"] == "websocket"
|
||||
|
||||
@patch("gateway.platforms.feishu.probe_bot", return_value=None)
|
||||
@patch("plugins.platforms.feishu.adapter.probe_bot", return_value=None)
|
||||
def test_manual_path_websocket(self, _mock_probe):
|
||||
env = _run_setup_feishu(
|
||||
qr_result=None,
|
||||
|
|
@ -129,7 +129,7 @@ class TestSetupFeishuConnectionMode:
|
|||
)
|
||||
assert env["FEISHU_CONNECTION_MODE"] == "websocket"
|
||||
|
||||
@patch("gateway.platforms.feishu.probe_bot", return_value=None)
|
||||
@patch("plugins.platforms.feishu.adapter.probe_bot", return_value=None)
|
||||
def test_manual_path_webhook(self, _mock_probe):
|
||||
env = _run_setup_feishu(
|
||||
qr_result=None,
|
||||
|
|
@ -248,7 +248,7 @@ class TestSetupFeishuAdapterIntegration:
|
|||
|
||||
with patch.dict(os.environ, env, clear=True):
|
||||
from gateway.config import PlatformConfig
|
||||
from gateway.platforms.feishu import FeishuAdapter
|
||||
from plugins.platforms.feishu.adapter import FeishuAdapter
|
||||
adapter = FeishuAdapter(PlatformConfig())
|
||||
assert adapter._app_id == "cli_test_app"
|
||||
assert adapter._app_secret == "test_secret_value"
|
||||
|
|
@ -261,7 +261,7 @@ class TestSetupFeishuAdapterIntegration:
|
|||
env = self._make_env_from_setup(dm_idx=1)
|
||||
|
||||
with patch.dict(os.environ, env, clear=True):
|
||||
from gateway.platforms.feishu import FeishuAdapter
|
||||
from plugins.platforms.feishu.adapter import FeishuAdapter
|
||||
from gateway.config import PlatformConfig
|
||||
# Verify adapter initializes without error and env var is correct.
|
||||
FeishuAdapter(PlatformConfig())
|
||||
|
|
@ -274,6 +274,6 @@ class TestSetupFeishuAdapterIntegration:
|
|||
|
||||
with patch.dict(os.environ, env, clear=True):
|
||||
from gateway.config import PlatformConfig
|
||||
from gateway.platforms.feishu import FeishuAdapter
|
||||
from plugins.platforms.feishu.adapter import FeishuAdapter
|
||||
adapter = FeishuAdapter(PlatformConfig())
|
||||
assert adapter._group_policy == "open"
|
||||
|
|
|
|||
|
|
@ -64,11 +64,11 @@ def _ensure_slack_mock():
|
|||
_ensure_slack_mock()
|
||||
|
||||
# Patch SLACK_AVAILABLE before importing the adapter
|
||||
import gateway.platforms.slack as _slack_mod
|
||||
import plugins.platforms.slack.adapter as _slack_mod
|
||||
|
||||
_slack_mod.SLACK_AVAILABLE = True
|
||||
|
||||
from gateway.platforms.slack import SlackAdapter # noqa: E402
|
||||
from plugins.platforms.slack.adapter import SlackAdapter # noqa: E402
|
||||
|
||||
|
||||
async def _pending_for_fake_task():
|
||||
|
|
@ -3627,7 +3627,7 @@ class TestSlashEphemeralAck:
|
|||
mock_session.__aexit__ = AsyncMock(return_value=False)
|
||||
|
||||
with patch(
|
||||
"gateway.platforms.slack.aiohttp.ClientSession", return_value=mock_session
|
||||
"plugins.platforms.slack.adapter.aiohttp.ClientSession", return_value=mock_session
|
||||
):
|
||||
result = await adapter.send("C_SLASH", "Queued for the next turn.")
|
||||
|
||||
|
|
@ -3677,7 +3677,7 @@ class TestSlashEphemeralAck:
|
|||
mock_session.__aexit__ = AsyncMock(return_value=False)
|
||||
|
||||
with patch(
|
||||
"gateway.platforms.slack.aiohttp.ClientSession", return_value=mock_session
|
||||
"plugins.platforms.slack.adapter.aiohttp.ClientSession", return_value=mock_session
|
||||
):
|
||||
result = await adapter.send("C1", "Some response")
|
||||
|
||||
|
|
@ -3700,7 +3700,7 @@ class TestSlashEphemeralAck:
|
|||
mock_session.__aexit__ = AsyncMock(return_value=False)
|
||||
|
||||
with patch(
|
||||
"gateway.platforms.slack.aiohttp.ClientSession", return_value=mock_session
|
||||
"plugins.platforms.slack.adapter.aiohttp.ClientSession", return_value=mock_session
|
||||
):
|
||||
result = await adapter.send("C1", "Some response")
|
||||
|
||||
|
|
@ -3766,7 +3766,7 @@ class TestSlashEphemeralAck:
|
|||
async def test_concurrent_users_same_channel_isolates_contexts(self, adapter):
|
||||
"""Two users slash on the same channel — each gets their own context."""
|
||||
import time
|
||||
from gateway.platforms.slack import _slash_user_id
|
||||
from plugins.platforms.slack.adapter import _slash_user_id
|
||||
|
||||
# Simulate two users stashing contexts on the same channel.
|
||||
adapter._slash_command_contexts[("C_SHARED", "U_ALICE")] = {
|
||||
|
|
@ -3806,7 +3806,7 @@ class TestSlashEphemeralAck:
|
|||
async def test_no_contextvar_does_not_match_any_context(self, adapter):
|
||||
"""send() without ContextVar (non-slash path) must not steal contexts."""
|
||||
import time
|
||||
from gateway.platforms.slack import _slash_user_id
|
||||
from plugins.platforms.slack.adapter import _slash_user_id
|
||||
|
||||
adapter._slash_command_contexts[("C1", "U1")] = {
|
||||
"response_url": "https://hooks.slack.com/test",
|
||||
|
|
|
|||
|
|
@ -42,7 +42,7 @@ def _ensure_slack_mock():
|
|||
|
||||
_ensure_slack_mock()
|
||||
|
||||
from gateway.platforms.slack import SlackAdapter
|
||||
from plugins.platforms.slack.adapter import SlackAdapter
|
||||
from gateway.config import PlatformConfig, Platform
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@ from unittest.mock import AsyncMock, MagicMock, patch
|
|||
import pytest
|
||||
|
||||
from gateway.config import PlatformConfig
|
||||
from gateway.platforms.slack import SlackAdapter
|
||||
from plugins.platforms.slack.adapter import SlackAdapter
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ from unittest.mock import MagicMock
|
|||
|
||||
def _make_adapter(extra=None):
|
||||
"""Create a minimal SlackAdapter stub with the given ``config.extra``."""
|
||||
from gateway.platforms.slack import SlackAdapter
|
||||
from plugins.platforms.slack.adapter import SlackAdapter
|
||||
adapter = object.__new__(SlackAdapter)
|
||||
adapter.config = MagicMock()
|
||||
adapter.config.extra = extra or {}
|
||||
|
|
|
|||
|
|
@ -40,10 +40,10 @@ def _ensure_slack_mock():
|
|||
|
||||
_ensure_slack_mock()
|
||||
|
||||
import gateway.platforms.slack as _slack_mod
|
||||
import plugins.platforms.slack.adapter as _slack_mod
|
||||
_slack_mod.SLACK_AVAILABLE = True
|
||||
|
||||
from gateway.platforms.slack import SlackAdapter # noqa: E402
|
||||
from plugins.platforms.slack.adapter import SlackAdapter # noqa: E402
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -58,11 +58,11 @@ def _ensure_slack_mock() -> None:
|
|||
|
||||
_ensure_slack_mock()
|
||||
|
||||
import gateway.platforms.slack as _slack_mod # noqa: E402
|
||||
import plugins.platforms.slack.adapter as _slack_mod # noqa: E402
|
||||
_slack_mod.SLACK_AVAILABLE = True
|
||||
|
||||
from gateway.config import PlatformConfig # noqa: E402
|
||||
from gateway.platforms.slack import SlackAdapter # noqa: E402
|
||||
from plugins.platforms.slack.adapter import SlackAdapter # noqa: E402
|
||||
|
||||
from hermes_cli.plugins import ( # noqa: E402
|
||||
PluginContext,
|
||||
|
|
|
|||
57
tests/gateway/test_slack_plugin_setup.py
Normal file
57
tests/gateway/test_slack_plugin_setup.py
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
"""Tests for the Slack plugin's interactive_setup wizard.
|
||||
|
||||
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.
|
||||
"""
|
||||
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):
|
||||
"""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, "save_env_value", lambda k, v: saved.update({k: v}))
|
||||
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)
|
||||
# Manifest writing reaches out to hermes_cli.slack_cli + filesystem; stub it.
|
||||
import hermes_cli.slack_cli as slack_cli_mod
|
||||
monkeypatch.setattr(slack_cli_mod, "_build_full_manifest", lambda **_kw: {"display_information": {}})
|
||||
|
||||
|
||||
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 = {}
|
||||
# prompts: bot token, app token, allowed users (empty), home channel
|
||||
_patch_setup_io(
|
||||
monkeypatch,
|
||||
["xoxb-test-token", "xapp-test-token", "", "C01ABC2DE3F"],
|
||||
saved,
|
||||
)
|
||||
|
||||
interactive_setup()
|
||||
|
||||
assert saved.get("SLACK_HOME_CHANNEL") == "C01ABC2DE3F"
|
||||
|
||||
|
||||
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 = {}
|
||||
_patch_setup_io(
|
||||
monkeypatch,
|
||||
["xoxb-test-token", "xapp-test-token", "", ""],
|
||||
saved,
|
||||
)
|
||||
|
||||
interactive_setup()
|
||||
|
||||
assert "SLACK_HOME_CHANNEL" not in saved
|
||||
|
|
@ -59,7 +59,7 @@ class TestSmsFormatAndTruncate:
|
|||
"""Test SmsAdapter.format_message strips markdown."""
|
||||
|
||||
def _make_adapter(self):
|
||||
from gateway.platforms.sms import SmsAdapter
|
||||
from plugins.platforms.sms.adapter import SmsAdapter
|
||||
|
||||
env = {
|
||||
"TWILIO_ACCOUNT_SID": "ACtest",
|
||||
|
|
@ -115,7 +115,7 @@ class TestSmsEchoPrevention:
|
|||
|
||||
def test_own_number_detection(self):
|
||||
"""The adapter stores _from_number for echo prevention."""
|
||||
from gateway.platforms.sms import SmsAdapter
|
||||
from plugins.platforms.sms.adapter import SmsAdapter
|
||||
|
||||
env = {
|
||||
"TWILIO_ACCOUNT_SID": "ACtest",
|
||||
|
|
@ -132,21 +132,21 @@ class TestSmsEchoPrevention:
|
|||
|
||||
class TestSmsRequirements:
|
||||
def test_check_sms_requirements_missing_sid(self):
|
||||
from gateway.platforms.sms import check_sms_requirements
|
||||
from plugins.platforms.sms.adapter import check_sms_requirements
|
||||
|
||||
env = {"TWILIO_AUTH_TOKEN": "tok"}
|
||||
with patch.dict(os.environ, env, clear=True):
|
||||
assert check_sms_requirements() is False
|
||||
|
||||
def test_check_sms_requirements_missing_token(self):
|
||||
from gateway.platforms.sms import check_sms_requirements
|
||||
from plugins.platforms.sms.adapter import check_sms_requirements
|
||||
|
||||
env = {"TWILIO_ACCOUNT_SID": "ACtest"}
|
||||
with patch.dict(os.environ, env, clear=True):
|
||||
assert check_sms_requirements() is False
|
||||
|
||||
def test_check_sms_requirements_both_set(self):
|
||||
from gateway.platforms.sms import check_sms_requirements
|
||||
from plugins.platforms.sms.adapter import check_sms_requirements
|
||||
|
||||
env = {
|
||||
"TWILIO_ACCOUNT_SID": "ACtest",
|
||||
|
|
@ -170,11 +170,11 @@ class TestWebhookHostConfig:
|
|||
"""Verify SMS_WEBHOOK_HOST env var and default."""
|
||||
|
||||
def test_default_host_is_localhost(self):
|
||||
from gateway.platforms.sms import DEFAULT_WEBHOOK_HOST
|
||||
from plugins.platforms.sms.adapter import DEFAULT_WEBHOOK_HOST
|
||||
assert DEFAULT_WEBHOOK_HOST == "127.0.0.1"
|
||||
|
||||
def test_host_from_env(self):
|
||||
from gateway.platforms.sms import SmsAdapter
|
||||
from plugins.platforms.sms.adapter import SmsAdapter
|
||||
|
||||
env = {
|
||||
"TWILIO_ACCOUNT_SID": "ACtest",
|
||||
|
|
@ -188,7 +188,7 @@ class TestWebhookHostConfig:
|
|||
assert adapter._webhook_host == "127.0.0.1"
|
||||
|
||||
def test_webhook_url_from_env(self):
|
||||
from gateway.platforms.sms import SmsAdapter
|
||||
from plugins.platforms.sms.adapter import SmsAdapter
|
||||
|
||||
env = {
|
||||
"TWILIO_ACCOUNT_SID": "ACtest",
|
||||
|
|
@ -202,7 +202,7 @@ class TestWebhookHostConfig:
|
|||
assert adapter._webhook_url == "https://example.com/webhooks/twilio"
|
||||
|
||||
def test_webhook_url_stripped(self):
|
||||
from gateway.platforms.sms import SmsAdapter
|
||||
from plugins.platforms.sms.adapter import SmsAdapter
|
||||
|
||||
env = {
|
||||
"TWILIO_ACCOUNT_SID": "ACtest",
|
||||
|
|
@ -222,7 +222,7 @@ class TestStartupGuard:
|
|||
"""Adapter must refuse to start without SMS_WEBHOOK_URL."""
|
||||
|
||||
def _make_adapter(self, extra_env=None):
|
||||
from gateway.platforms.sms import SmsAdapter
|
||||
from plugins.platforms.sms.adapter import SmsAdapter
|
||||
|
||||
env = {
|
||||
"TWILIO_ACCOUNT_SID": "ACtest",
|
||||
|
|
@ -252,7 +252,7 @@ class TestStartupGuard:
|
|||
|
||||
@pytest.mark.asyncio
|
||||
async def test_missing_phone_number_is_non_retryable(self):
|
||||
from gateway.platforms.sms import SmsAdapter
|
||||
from plugins.platforms.sms.adapter import SmsAdapter
|
||||
|
||||
env = {
|
||||
"TWILIO_ACCOUNT_SID": "ACtest",
|
||||
|
|
@ -335,7 +335,7 @@ class TestTwilioSignatureValidation:
|
|||
"""Unit tests for SmsAdapter._validate_twilio_signature."""
|
||||
|
||||
def _make_adapter(self, auth_token="test_token_secret"):
|
||||
from gateway.platforms.sms import SmsAdapter
|
||||
from plugins.platforms.sms.adapter import SmsAdapter
|
||||
|
||||
env = {
|
||||
"TWILIO_ACCOUNT_SID": "ACtest",
|
||||
|
|
@ -445,7 +445,7 @@ class TestWebhookSignatureEnforcement:
|
|||
"""Integration tests for signature validation in _handle_webhook."""
|
||||
|
||||
def _make_adapter(self, webhook_url=""):
|
||||
from gateway.platforms.sms import SmsAdapter
|
||||
from plugins.platforms.sms.adapter import SmsAdapter
|
||||
|
||||
env = {
|
||||
"TWILIO_ACCOUNT_SID": "ACtest",
|
||||
|
|
|
|||
|
|
@ -148,14 +148,14 @@ class TestEditMessageFinalizeSignature:
|
|||
@pytest.mark.parametrize(
|
||||
"module_path,class_name",
|
||||
[
|
||||
("gateway.platforms.telegram", "TelegramAdapter"),
|
||||
("plugins.platforms.telegram.adapter", "TelegramAdapter"),
|
||||
("plugins.platforms.discord.adapter", "DiscordAdapter"),
|
||||
("gateway.platforms.slack", "SlackAdapter"),
|
||||
("gateway.platforms.matrix", "MatrixAdapter"),
|
||||
("plugins.platforms.slack.adapter", "SlackAdapter"),
|
||||
("plugins.platforms.matrix.adapter", "MatrixAdapter"),
|
||||
("plugins.platforms.mattermost.adapter", "MattermostAdapter"),
|
||||
("gateway.platforms.feishu", "FeishuAdapter"),
|
||||
("gateway.platforms.whatsapp", "WhatsAppAdapter"),
|
||||
("gateway.platforms.dingtalk", "DingTalkAdapter"),
|
||||
("plugins.platforms.feishu.adapter", "FeishuAdapter"),
|
||||
("plugins.platforms.whatsapp.adapter", "WhatsAppAdapter"),
|
||||
("plugins.platforms.dingtalk.adapter", "DingTalkAdapter"),
|
||||
],
|
||||
)
|
||||
def test_edit_message_accepts_finalize(self, module_path, class_name):
|
||||
|
|
|
|||
|
|
@ -646,7 +646,7 @@ class TestTelegramAdapterDeleteMessage:
|
|||
"""Contract: Telegram adapter implements ``delete_message``."""
|
||||
|
||||
def test_delete_message_method_exists(self):
|
||||
telegram = pytest.importorskip("gateway.platforms.telegram")
|
||||
telegram = pytest.importorskip("plugins.platforms.telegram.adapter")
|
||||
import inspect
|
||||
cls = telegram.TelegramAdapter
|
||||
assert hasattr(cls, "delete_message"), (
|
||||
|
|
|
|||
|
|
@ -180,7 +180,7 @@ class TestFeishuFallbackThreadRouting:
|
|||
async def test_create_uses_thread_id_when_available(self):
|
||||
"""When reply_to=None and metadata has thread_id, message.create
|
||||
should use receive_id_type='thread_id'."""
|
||||
from gateway.platforms.feishu import FeishuAdapter
|
||||
from plugins.platforms.feishu.adapter import FeishuAdapter
|
||||
|
||||
# We test the _send_raw_message method directly by mocking the client
|
||||
adapter = MagicMock(spec=FeishuAdapter)
|
||||
|
|
@ -237,7 +237,7 @@ class TestFeishuFallbackThreadRouting:
|
|||
async def test_create_uses_chat_id_when_no_thread(self):
|
||||
"""When reply_to=None and metadata has no thread_id, message.create
|
||||
should use receive_id_type='chat_id' (original behavior)."""
|
||||
from gateway.platforms.feishu import FeishuAdapter
|
||||
from plugins.platforms.feishu.adapter import FeishuAdapter
|
||||
|
||||
mock_client = MagicMock()
|
||||
mock_create_response = SimpleNamespace(
|
||||
|
|
|
|||
|
|
@ -46,7 +46,7 @@ def _ensure_telegram_mock():
|
|||
|
||||
_ensure_telegram_mock()
|
||||
|
||||
from gateway.platforms.telegram import TelegramAdapter
|
||||
from plugins.platforms.telegram.adapter import TelegramAdapter
|
||||
from gateway.config import Platform, PlatformConfig
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -55,7 +55,7 @@ def _inject_fake_telegram(monkeypatch):
|
|||
|
||||
|
||||
def _make_adapter():
|
||||
from gateway.platforms.telegram import TelegramAdapter
|
||||
from plugins.platforms.telegram.adapter import TelegramAdapter
|
||||
|
||||
config = PlatformConfig(enabled=True, token="fake-token")
|
||||
adapter = object.__new__(TelegramAdapter)
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
"""Tests for TelegramPlatform._merge_caption caption deduplication logic."""
|
||||
|
||||
|
||||
from gateway.platforms.telegram import TelegramAdapter
|
||||
from plugins.platforms.telegram.adapter import TelegramAdapter
|
||||
|
||||
merge = TelegramAdapter._merge_caption
|
||||
|
||||
|
|
|
|||
|
|
@ -63,7 +63,7 @@ def _build_telegram_stubs():
|
|||
@pytest.fixture
|
||||
def telegram_adapter_cls(monkeypatch):
|
||||
"""Import TelegramAdapter without leaking temporary telegram stubs."""
|
||||
module_name = "gateway.platforms.telegram"
|
||||
module_name = "plugins.platforms.telegram.adapter"
|
||||
existing_module = sys.modules.get(module_name)
|
||||
if existing_module is not None:
|
||||
yield existing_module.TelegramAdapter
|
||||
|
|
|
|||
|
|
@ -47,7 +47,7 @@ def _ensure_telegram_mock():
|
|||
|
||||
_ensure_telegram_mock()
|
||||
|
||||
from gateway.platforms.telegram import TelegramAdapter
|
||||
from plugins.platforms.telegram.adapter import TelegramAdapter
|
||||
from gateway.config import PlatformConfig
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -34,7 +34,7 @@ def _ensure_telegram_mock():
|
|||
|
||||
_ensure_telegram_mock()
|
||||
|
||||
from gateway.platforms.telegram import TelegramAdapter # noqa: E402
|
||||
from plugins.platforms.telegram.adapter import TelegramAdapter # noqa: E402
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
|
|
@ -42,9 +42,9 @@ def _no_auto_discovery(monkeypatch):
|
|||
"""Disable DoH auto-discovery so connect() uses the plain builder chain."""
|
||||
async def _noop():
|
||||
return []
|
||||
monkeypatch.setattr("gateway.platforms.telegram.discover_fallback_ips", _noop)
|
||||
monkeypatch.setattr("plugins.platforms.telegram.adapter.discover_fallback_ips", _noop)
|
||||
# Mock HTTPXRequest so the builder chain doesn't fail
|
||||
monkeypatch.setattr("gateway.platforms.telegram.HTTPXRequest", lambda **kwargs: MagicMock())
|
||||
monkeypatch.setattr("plugins.platforms.telegram.adapter.HTTPXRequest", lambda **kwargs: MagicMock())
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
|
@ -103,7 +103,7 @@ async def test_polling_conflict_retries_before_fatal(monkeypatch):
|
|||
builder.request.return_value = builder
|
||||
builder.get_updates_request.return_value = builder
|
||||
builder.build.return_value = app
|
||||
monkeypatch.setattr("gateway.platforms.telegram.Application", SimpleNamespace(builder=MagicMock(return_value=builder)))
|
||||
monkeypatch.setattr("plugins.platforms.telegram.adapter.Application", SimpleNamespace(builder=MagicMock(return_value=builder)))
|
||||
|
||||
# Speed up retries for testing
|
||||
monkeypatch.setattr("asyncio.sleep", AsyncMock())
|
||||
|
|
@ -179,7 +179,7 @@ async def test_polling_conflict_becomes_fatal_after_retries(monkeypatch):
|
|||
builder.request.return_value = builder
|
||||
builder.get_updates_request.return_value = builder
|
||||
builder.build.return_value = app
|
||||
monkeypatch.setattr("gateway.platforms.telegram.Application", SimpleNamespace(builder=MagicMock(return_value=builder)))
|
||||
monkeypatch.setattr("plugins.platforms.telegram.adapter.Application", SimpleNamespace(builder=MagicMock(return_value=builder)))
|
||||
|
||||
# Speed up retries for testing
|
||||
monkeypatch.setattr("asyncio.sleep", AsyncMock())
|
||||
|
|
@ -232,7 +232,7 @@ async def test_connect_marks_retryable_fatal_error_for_startup_network_failure(m
|
|||
start=AsyncMock(),
|
||||
)
|
||||
builder.build.return_value = app
|
||||
monkeypatch.setattr("gateway.platforms.telegram.Application", SimpleNamespace(builder=MagicMock(return_value=builder)))
|
||||
monkeypatch.setattr("plugins.platforms.telegram.adapter.Application", SimpleNamespace(builder=MagicMock(return_value=builder)))
|
||||
|
||||
ok = await adapter.connect()
|
||||
|
||||
|
|
@ -277,7 +277,7 @@ async def test_connect_clears_webhook_before_polling(monkeypatch):
|
|||
builder.get_updates_request.return_value = builder
|
||||
builder.build.return_value = app
|
||||
monkeypatch.setattr(
|
||||
"gateway.platforms.telegram.Application",
|
||||
"plugins.platforms.telegram.adapter.Application",
|
||||
SimpleNamespace(builder=MagicMock(return_value=builder)),
|
||||
)
|
||||
|
||||
|
|
@ -301,7 +301,7 @@ async def test_disconnect_skips_inactive_updater_and_app(monkeypatch):
|
|||
adapter._app = app
|
||||
|
||||
warning = MagicMock()
|
||||
monkeypatch.setattr("gateway.platforms.telegram.logger.warning", warning)
|
||||
monkeypatch.setattr("plugins.platforms.telegram.adapter.logger.warning", warning)
|
||||
|
||||
await adapter.disconnect()
|
||||
|
||||
|
|
@ -367,7 +367,7 @@ async def test_polling_conflict_reschedule_uses_running_loop(monkeypatch):
|
|||
builder.get_updates_request.return_value = builder
|
||||
builder.build.return_value = app
|
||||
monkeypatch.setattr(
|
||||
"gateway.platforms.telegram.Application",
|
||||
"plugins.platforms.telegram.adapter.Application",
|
||||
SimpleNamespace(builder=MagicMock(return_value=builder)),
|
||||
)
|
||||
monkeypatch.setattr("asyncio.sleep", AsyncMock())
|
||||
|
|
|
|||
|
|
@ -51,7 +51,7 @@ def _ensure_telegram_mock():
|
|||
_ensure_telegram_mock()
|
||||
|
||||
# Now we can safely import
|
||||
from gateway.platforms.telegram import TelegramAdapter # noqa: E402
|
||||
from plugins.platforms.telegram.adapter import TelegramAdapter # noqa: E402
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
@ -442,7 +442,7 @@ class TestMediaGroups:
|
|||
msg1 = _make_message(caption="two images", photo=[first_photo])
|
||||
msg2 = _make_message(photo=[second_photo])
|
||||
|
||||
with patch("gateway.platforms.telegram.cache_image_from_bytes", side_effect=["/tmp/burst-one.jpg", "/tmp/burst-two.jpg"]):
|
||||
with patch("plugins.platforms.telegram.adapter.cache_image_from_bytes", side_effect=["/tmp/burst-one.jpg", "/tmp/burst-two.jpg"]):
|
||||
await adapter._handle_media_message(_make_update(msg1), MagicMock())
|
||||
await adapter._handle_media_message(_make_update(msg2), MagicMock())
|
||||
assert adapter.handle_message.await_count == 0
|
||||
|
|
@ -462,7 +462,7 @@ class TestMediaGroups:
|
|||
msg1 = _make_message(caption="two images", media_group_id="album-1", photo=[first_photo])
|
||||
msg2 = _make_message(media_group_id="album-1", photo=[second_photo])
|
||||
|
||||
with patch("gateway.platforms.telegram.cache_image_from_bytes", side_effect=["/tmp/one.jpg", "/tmp/two.jpg"]):
|
||||
with patch("plugins.platforms.telegram.adapter.cache_image_from_bytes", side_effect=["/tmp/one.jpg", "/tmp/two.jpg"]):
|
||||
await adapter._handle_media_message(_make_update(msg1), MagicMock())
|
||||
await adapter._handle_media_message(_make_update(msg2), MagicMock())
|
||||
assert adapter.handle_message.await_count == 0
|
||||
|
|
@ -479,7 +479,7 @@ class TestMediaGroups:
|
|||
first_photo = _make_photo(_make_file_obj(b"first"))
|
||||
msg = _make_message(caption="two images", media_group_id="album-2", photo=[first_photo])
|
||||
|
||||
with patch("gateway.platforms.telegram.cache_image_from_bytes", return_value="/tmp/one.jpg"):
|
||||
with patch("plugins.platforms.telegram.adapter.cache_image_from_bytes", return_value="/tmp/one.jpg"):
|
||||
await adapter._handle_media_message(_make_update(msg), MagicMock())
|
||||
|
||||
assert "album-2" in adapter._media_group_events
|
||||
|
|
@ -782,8 +782,8 @@ class TestTelegramPhotoBatching:
|
|||
)
|
||||
|
||||
with (
|
||||
patch("gateway.platforms.telegram.asyncio.current_task", return_value=old_task),
|
||||
patch("gateway.platforms.telegram.asyncio.sleep", new=AsyncMock()),
|
||||
patch("plugins.platforms.telegram.adapter.asyncio.current_task", return_value=old_task),
|
||||
patch("plugins.platforms.telegram.adapter.asyncio.sleep", new=AsyncMock()),
|
||||
):
|
||||
await adapter._flush_photo_batch(batch_key)
|
||||
|
||||
|
|
|
|||
|
|
@ -35,7 +35,7 @@ def _ensure_telegram_mock():
|
|||
|
||||
_ensure_telegram_mock()
|
||||
|
||||
from gateway.platforms.telegram import ( # noqa: E402
|
||||
from plugins.platforms.telegram.adapter import ( # noqa: E402
|
||||
TelegramAdapter,
|
||||
_escape_mdv2,
|
||||
_strip_mdv2,
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ from gateway.config import Platform, PlatformConfig
|
|||
|
||||
def _make_test_adapter():
|
||||
"""Build a TelegramAdapter without running __init__."""
|
||||
from gateway.platforms.telegram import TelegramAdapter
|
||||
from plugins.platforms.telegram.adapter import TelegramAdapter
|
||||
|
||||
adapter = object.__new__(TelegramAdapter)
|
||||
adapter.platform = Platform.TELEGRAM
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@ def _make_adapter(
|
|||
observe_unmentioned_group_messages=None,
|
||||
bot_username="hermes_bot",
|
||||
):
|
||||
from gateway.platforms.telegram import TelegramAdapter
|
||||
from plugins.platforms.telegram.adapter import TelegramAdapter
|
||||
|
||||
extra = {}
|
||||
if require_mention is not None:
|
||||
|
|
|
|||
|
|
@ -29,7 +29,7 @@ def _ensure_telegram_mock():
|
|||
|
||||
_ensure_telegram_mock()
|
||||
|
||||
from gateway.platforms.telegram import TelegramAdapter # noqa: E402
|
||||
from plugins.platforms.telegram.adapter import TelegramAdapter # noqa: E402
|
||||
|
||||
|
||||
def test_max_doc_bytes_defaults_to_20mb_without_base_url():
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ those contexts.
|
|||
from types import SimpleNamespace
|
||||
|
||||
from gateway.config import Platform, PlatformConfig
|
||||
from gateway.platforms.telegram import TelegramAdapter
|
||||
from plugins.platforms.telegram.adapter import TelegramAdapter
|
||||
|
||||
|
||||
def _make_adapter():
|
||||
|
|
|
|||
|
|
@ -32,7 +32,7 @@ def _ensure_telegram_mock():
|
|||
_ensure_telegram_mock()
|
||||
|
||||
from gateway.config import PlatformConfig
|
||||
from gateway.platforms.telegram import TelegramAdapter
|
||||
from plugins.platforms.telegram.adapter import TelegramAdapter
|
||||
|
||||
|
||||
def _make_adapter():
|
||||
|
|
@ -147,7 +147,7 @@ class TestTelegramModelPicker:
|
|||
which is robust to whether `telegram` is the real SDK or the module
|
||||
mock (the SDK markup objects don't expose a plain iterable under the
|
||||
mock)."""
|
||||
import gateway.platforms.telegram as tg
|
||||
import plugins.platforms.telegram.adapter as tg
|
||||
|
||||
built: list = []
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
"""Tests for gateway.platforms.telegram_network – fallback transport layer.
|
||||
"""Tests for plugins.platforms.telegram.telegram_network – fallback transport layer.
|
||||
|
||||
Background
|
||||
----------
|
||||
|
|
@ -18,7 +18,7 @@ fallback IPs in order, then "stick" to whichever IP works.
|
|||
import httpx
|
||||
import pytest
|
||||
|
||||
from gateway.platforms import telegram_network as tnet
|
||||
import plugins.platforms.telegram.telegram_network as tnet
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
@ -438,7 +438,7 @@ class TestAdapterFallbackIps:
|
|||
sys.modules.setdefault(name, mod)
|
||||
|
||||
from gateway.config import PlatformConfig
|
||||
from gateway.platforms.telegram import TelegramAdapter
|
||||
from plugins.platforms.telegram.adapter import TelegramAdapter
|
||||
|
||||
config = PlatformConfig(enabled=True, token="test-token")
|
||||
if extra:
|
||||
|
|
|
|||
|
|
@ -33,7 +33,7 @@ def _ensure_telegram_mock():
|
|||
|
||||
_ensure_telegram_mock()
|
||||
|
||||
from gateway.platforms.telegram import TelegramAdapter # noqa: E402
|
||||
from plugins.platforms.telegram.adapter import TelegramAdapter # noqa: E402
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
|
|
@ -41,7 +41,7 @@ def _no_auto_discovery(monkeypatch):
|
|||
"""Disable DoH auto-discovery so connect() uses the plain builder chain."""
|
||||
async def _noop():
|
||||
return []
|
||||
monkeypatch.setattr("gateway.platforms.telegram.discover_fallback_ips", _noop)
|
||||
monkeypatch.setattr("plugins.platforms.telegram.adapter.discover_fallback_ips", _noop)
|
||||
|
||||
|
||||
def _make_adapter() -> TelegramAdapter:
|
||||
|
|
@ -379,7 +379,7 @@ async def test_heartbeat_probe_reenters_ladder_when_get_me_times_out():
|
|||
raise asyncio.TimeoutError()
|
||||
|
||||
with patch("asyncio.sleep", new_callable=AsyncMock):
|
||||
with patch("gateway.platforms.telegram.asyncio.wait_for", new=fast_wait_for):
|
||||
with patch("plugins.platforms.telegram.adapter.asyncio.wait_for", new=fast_wait_for):
|
||||
await adapter._verify_polling_after_reconnect()
|
||||
|
||||
adapter._handle_polling_network_error.assert_awaited_once()
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ import pytest
|
|||
|
||||
from gateway.config import PlatformConfig
|
||||
from gateway.platforms.base import SendResult
|
||||
from gateway.platforms.telegram import TelegramAdapter
|
||||
from plugins.platforms.telegram.adapter import TelegramAdapter
|
||||
from gateway.stream_consumer import GatewayStreamConsumer
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ from gateway.session import SessionSource
|
|||
|
||||
|
||||
def _make_adapter(**extra_env):
|
||||
from gateway.platforms.telegram import TelegramAdapter
|
||||
from plugins.platforms.telegram.adapter import TelegramAdapter
|
||||
|
||||
adapter = object.__new__(TelegramAdapter)
|
||||
adapter.platform = Platform.TELEGRAM
|
||||
|
|
|
|||
|
|
@ -31,7 +31,7 @@ def _ensure_telegram_mock():
|
|||
|
||||
_ensure_telegram_mock()
|
||||
|
||||
from gateway.platforms.telegram import TelegramAdapter # noqa: E402
|
||||
from plugins.platforms.telegram.adapter import TelegramAdapter # noqa: E402
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
|
|
|
|||
|
|
@ -33,7 +33,7 @@ def _ensure_telegram_mock():
|
|||
|
||||
_ensure_telegram_mock()
|
||||
|
||||
from gateway.platforms.telegram import TelegramAdapter # noqa: E402
|
||||
from plugins.platforms.telegram.adapter import TelegramAdapter # noqa: E402
|
||||
|
||||
|
||||
def _make_adapter():
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ import pytest
|
|||
|
||||
from gateway.config import PlatformConfig
|
||||
from gateway.platforms.base import SendResult
|
||||
from gateway.platforms.telegram import TelegramAdapter
|
||||
from plugins.platforms.telegram.adapter import TelegramAdapter
|
||||
from telegram.error import BadRequest, NetworkError, TimedOut
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -35,8 +35,8 @@ def _ensure_telegram_mock():
|
|||
|
||||
_ensure_telegram_mock()
|
||||
|
||||
from gateway.platforms import telegram as tg_mod # noqa: E402
|
||||
from gateway.platforms.telegram import TelegramAdapter # noqa: E402
|
||||
import plugins.platforms.telegram.adapter as tg_mod # noqa: E402
|
||||
from plugins.platforms.telegram.adapter import TelegramAdapter # noqa: E402
|
||||
|
||||
|
||||
def _make_adapter() -> TelegramAdapter:
|
||||
|
|
|
|||
|
|
@ -27,7 +27,7 @@ def _ensure_telegram_mock():
|
|||
|
||||
_ensure_telegram_mock()
|
||||
|
||||
from gateway.platforms.telegram import TelegramAdapter # noqa: E402
|
||||
from plugins.platforms.telegram.adapter import TelegramAdapter # noqa: E402
|
||||
|
||||
|
||||
def _make_adapter() -> TelegramAdapter:
|
||||
|
|
@ -78,12 +78,12 @@ async def test_reconnect_storm_sets_and_heartbeat_clears_flag(monkeypatch):
|
|||
adapter._app.bot.get_me = AsyncMock(return_value=MagicMock())
|
||||
adapter._polling_error_callback_ref = AsyncMock()
|
||||
monkeypatch.setattr(
|
||||
"gateway.platforms.telegram.Update", MagicMock(ALL_TYPES=[])
|
||||
"plugins.platforms.telegram.adapter.Update", MagicMock(ALL_TYPES=[])
|
||||
)
|
||||
|
||||
await adapter._handle_polling_network_error(OSError("Bad Gateway"))
|
||||
assert adapter._send_path_degraded is True
|
||||
|
||||
with patch("gateway.platforms.telegram.asyncio.sleep", new_callable=AsyncMock):
|
||||
with patch("plugins.platforms.telegram.adapter.asyncio.sleep", new_callable=AsyncMock):
|
||||
await adapter._verify_polling_after_reconnect()
|
||||
assert adapter._send_path_degraded is False
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue