mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-04-25 00:51:20 +00:00
feat(gateway): add Feishu/Lark platform support (#3817)
Adds Feishu (ByteDance's enterprise messaging platform) as a gateway platform adapter with full feature parity: WebSocket + webhook transports, message batching, dedup, rate limiting, rich post/card content parsing, media handling (images/audio/files/video), group @mention gating, reaction routing, and interactive card button support. Cherry-picked from PR #1793 by penwyp with: - Moved to current main (PR was 458 commits behind) - Fixed _send_with_retry shadowing BasePlatformAdapter method (renamed to _feishu_send_with_retry to avoid signature mismatch crash) - Fixed import structure: aiohttp/websockets imported independently of lark_oapi so they remain available when SDK is missing - Fixed get_hermes_home import (hermes_constants, not hermes_cli.config) - Added skip decorators for tests requiring lark_oapi SDK - All 16 integration points added surgically to current main New dependency: lark-oapi>=1.5.3,<2 (optional, pip install hermes-agent[feishu]) Fixes #1788 Co-authored-by: penwyp <penwyp@users.noreply.github.com>
This commit is contained in:
parent
e314833c9d
commit
ca4907dfbc
19 changed files with 6135 additions and 9 deletions
|
|
@ -146,6 +146,7 @@ def _deliver_result(job: dict, content: str) -> None:
|
||||||
"mattermost": Platform.MATTERMOST,
|
"mattermost": Platform.MATTERMOST,
|
||||||
"homeassistant": Platform.HOMEASSISTANT,
|
"homeassistant": Platform.HOMEASSISTANT,
|
||||||
"dingtalk": Platform.DINGTALK,
|
"dingtalk": Platform.DINGTALK,
|
||||||
|
"feishu": Platform.FEISHU,
|
||||||
"email": Platform.EMAIL,
|
"email": Platform.EMAIL,
|
||||||
"sms": Platform.SMS,
|
"sms": Platform.SMS,
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -57,6 +57,7 @@ class Platform(Enum):
|
||||||
DINGTALK = "dingtalk"
|
DINGTALK = "dingtalk"
|
||||||
API_SERVER = "api_server"
|
API_SERVER = "api_server"
|
||||||
WEBHOOK = "webhook"
|
WEBHOOK = "webhook"
|
||||||
|
FEISHU = "feishu"
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
|
|
@ -274,6 +275,9 @@ class GatewayConfig:
|
||||||
# Webhook uses enabled flag only (secrets are per-route)
|
# Webhook uses enabled flag only (secrets are per-route)
|
||||||
elif platform == Platform.WEBHOOK:
|
elif platform == Platform.WEBHOOK:
|
||||||
connected.append(platform)
|
connected.append(platform)
|
||||||
|
# Feishu uses extra dict for app credentials
|
||||||
|
elif platform == Platform.FEISHU and config.extra.get("app_id"):
|
||||||
|
connected.append(platform)
|
||||||
return connected
|
return connected
|
||||||
|
|
||||||
def get_home_channel(self, platform: Platform) -> Optional[HomeChannel]:
|
def get_home_channel(self, platform: Platform) -> Optional[HomeChannel]:
|
||||||
|
|
@ -810,6 +814,33 @@ def _apply_env_overrides(config: GatewayConfig) -> None:
|
||||||
if webhook_secret:
|
if webhook_secret:
|
||||||
config.platforms[Platform.WEBHOOK].extra["secret"] = webhook_secret
|
config.platforms[Platform.WEBHOOK].extra["secret"] = webhook_secret
|
||||||
|
|
||||||
|
# Feishu / Lark
|
||||||
|
feishu_app_id = os.getenv("FEISHU_APP_ID")
|
||||||
|
feishu_app_secret = os.getenv("FEISHU_APP_SECRET")
|
||||||
|
if feishu_app_id and feishu_app_secret:
|
||||||
|
if Platform.FEISHU not in config.platforms:
|
||||||
|
config.platforms[Platform.FEISHU] = PlatformConfig()
|
||||||
|
config.platforms[Platform.FEISHU].enabled = True
|
||||||
|
config.platforms[Platform.FEISHU].extra.update({
|
||||||
|
"app_id": feishu_app_id,
|
||||||
|
"app_secret": feishu_app_secret,
|
||||||
|
"domain": os.getenv("FEISHU_DOMAIN", "feishu"),
|
||||||
|
"connection_mode": os.getenv("FEISHU_CONNECTION_MODE", "websocket"),
|
||||||
|
})
|
||||||
|
feishu_encrypt_key = os.getenv("FEISHU_ENCRYPT_KEY", "")
|
||||||
|
if feishu_encrypt_key:
|
||||||
|
config.platforms[Platform.FEISHU].extra["encrypt_key"] = feishu_encrypt_key
|
||||||
|
feishu_verification_token = os.getenv("FEISHU_VERIFICATION_TOKEN", "")
|
||||||
|
if feishu_verification_token:
|
||||||
|
config.platforms[Platform.FEISHU].extra["verification_token"] = feishu_verification_token
|
||||||
|
feishu_home = os.getenv("FEISHU_HOME_CHANNEL")
|
||||||
|
if feishu_home:
|
||||||
|
config.platforms[Platform.FEISHU].home_channel = HomeChannel(
|
||||||
|
platform=Platform.FEISHU,
|
||||||
|
chat_id=feishu_home,
|
||||||
|
name=os.getenv("FEISHU_HOME_CHANNEL_NAME", "Home"),
|
||||||
|
)
|
||||||
|
|
||||||
# Session settings
|
# Session settings
|
||||||
idle_minutes = os.getenv("SESSION_IDLE_MINUTES")
|
idle_minutes = os.getenv("SESSION_IDLE_MINUTES")
|
||||||
if idle_minutes:
|
if idle_minutes:
|
||||||
|
|
|
||||||
3255
gateway/platforms/feishu.py
Normal file
3255
gateway/platforms/feishu.py
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -983,6 +983,7 @@ class GatewayRunner:
|
||||||
"EMAIL_ALLOWED_USERS",
|
"EMAIL_ALLOWED_USERS",
|
||||||
"SMS_ALLOWED_USERS", "MATTERMOST_ALLOWED_USERS",
|
"SMS_ALLOWED_USERS", "MATTERMOST_ALLOWED_USERS",
|
||||||
"MATRIX_ALLOWED_USERS", "DINGTALK_ALLOWED_USERS",
|
"MATRIX_ALLOWED_USERS", "DINGTALK_ALLOWED_USERS",
|
||||||
|
"FEISHU_ALLOWED_USERS",
|
||||||
"GATEWAY_ALLOWED_USERS")
|
"GATEWAY_ALLOWED_USERS")
|
||||||
)
|
)
|
||||||
_allow_all = os.getenv("GATEWAY_ALLOW_ALL_USERS", "").lower() in ("true", "1", "yes") or any(
|
_allow_all = os.getenv("GATEWAY_ALLOW_ALL_USERS", "").lower() in ("true", "1", "yes") or any(
|
||||||
|
|
@ -991,7 +992,8 @@ class GatewayRunner:
|
||||||
"WHATSAPP_ALLOW_ALL_USERS", "SLACK_ALLOW_ALL_USERS",
|
"WHATSAPP_ALLOW_ALL_USERS", "SLACK_ALLOW_ALL_USERS",
|
||||||
"SIGNAL_ALLOW_ALL_USERS", "EMAIL_ALLOW_ALL_USERS",
|
"SIGNAL_ALLOW_ALL_USERS", "EMAIL_ALLOW_ALL_USERS",
|
||||||
"SMS_ALLOW_ALL_USERS", "MATTERMOST_ALLOW_ALL_USERS",
|
"SMS_ALLOW_ALL_USERS", "MATTERMOST_ALLOW_ALL_USERS",
|
||||||
"MATRIX_ALLOW_ALL_USERS", "DINGTALK_ALLOW_ALL_USERS")
|
"MATRIX_ALLOW_ALL_USERS", "DINGTALK_ALLOW_ALL_USERS",
|
||||||
|
"FEISHU_ALLOW_ALL_USERS")
|
||||||
)
|
)
|
||||||
if not _any_allowlist and not _allow_all:
|
if not _any_allowlist and not _allow_all:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
|
|
@ -1434,6 +1436,13 @@ class GatewayRunner:
|
||||||
return None
|
return None
|
||||||
return DingTalkAdapter(config)
|
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.MATTERMOST:
|
elif platform == Platform.MATTERMOST:
|
||||||
from gateway.platforms.mattermost import MattermostAdapter, check_mattermost_requirements
|
from gateway.platforms.mattermost import MattermostAdapter, check_mattermost_requirements
|
||||||
if not check_mattermost_requirements():
|
if not check_mattermost_requirements():
|
||||||
|
|
@ -1500,6 +1509,7 @@ class GatewayRunner:
|
||||||
Platform.MATTERMOST: "MATTERMOST_ALLOWED_USERS",
|
Platform.MATTERMOST: "MATTERMOST_ALLOWED_USERS",
|
||||||
Platform.MATRIX: "MATRIX_ALLOWED_USERS",
|
Platform.MATRIX: "MATRIX_ALLOWED_USERS",
|
||||||
Platform.DINGTALK: "DINGTALK_ALLOWED_USERS",
|
Platform.DINGTALK: "DINGTALK_ALLOWED_USERS",
|
||||||
|
Platform.FEISHU: "FEISHU_ALLOWED_USERS",
|
||||||
}
|
}
|
||||||
platform_allow_all_map = {
|
platform_allow_all_map = {
|
||||||
Platform.TELEGRAM: "TELEGRAM_ALLOW_ALL_USERS",
|
Platform.TELEGRAM: "TELEGRAM_ALLOW_ALL_USERS",
|
||||||
|
|
@ -1512,6 +1522,7 @@ class GatewayRunner:
|
||||||
Platform.MATTERMOST: "MATTERMOST_ALLOW_ALL_USERS",
|
Platform.MATTERMOST: "MATTERMOST_ALLOW_ALL_USERS",
|
||||||
Platform.MATRIX: "MATRIX_ALLOW_ALL_USERS",
|
Platform.MATRIX: "MATRIX_ALLOW_ALL_USERS",
|
||||||
Platform.DINGTALK: "DINGTALK_ALLOW_ALL_USERS",
|
Platform.DINGTALK: "DINGTALK_ALLOW_ALL_USERS",
|
||||||
|
Platform.FEISHU: "FEISHU_ALLOW_ALL_USERS",
|
||||||
}
|
}
|
||||||
|
|
||||||
# Per-platform allow-all flag (e.g., DISCORD_ALLOW_ALL_USERS=true)
|
# Per-platform allow-all flag (e.g., DISCORD_ALLOW_ALL_USERS=true)
|
||||||
|
|
|
||||||
|
|
@ -34,6 +34,7 @@ _EXTRA_ENV_KEYS = frozenset({
|
||||||
"SIGNAL_ACCOUNT", "SIGNAL_HTTP_URL",
|
"SIGNAL_ACCOUNT", "SIGNAL_HTTP_URL",
|
||||||
"SIGNAL_ALLOWED_USERS", "SIGNAL_GROUP_ALLOWED_USERS",
|
"SIGNAL_ALLOWED_USERS", "SIGNAL_GROUP_ALLOWED_USERS",
|
||||||
"DINGTALK_CLIENT_ID", "DINGTALK_CLIENT_SECRET",
|
"DINGTALK_CLIENT_ID", "DINGTALK_CLIENT_SECRET",
|
||||||
|
"FEISHU_APP_ID", "FEISHU_APP_SECRET", "FEISHU_ENCRYPT_KEY", "FEISHU_VERIFICATION_TOKEN",
|
||||||
"TERMINAL_ENV", "TERMINAL_SSH_KEY", "TERMINAL_SSH_PORT",
|
"TERMINAL_ENV", "TERMINAL_SSH_KEY", "TERMINAL_SSH_PORT",
|
||||||
"WHATSAPP_MODE", "WHATSAPP_ENABLED",
|
"WHATSAPP_MODE", "WHATSAPP_ENABLED",
|
||||||
"MATTERMOST_HOME_CHANNEL", "MATTERMOST_REPLY_MODE",
|
"MATTERMOST_HOME_CHANNEL", "MATTERMOST_REPLY_MODE",
|
||||||
|
|
|
||||||
|
|
@ -1322,6 +1322,35 @@ _PLATFORMS = [
|
||||||
"help": "The AppSecret from your DingTalk application credentials."},
|
"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."},
|
||||||
|
],
|
||||||
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,7 @@ PLATFORMS = {
|
||||||
"mattermost": "💬 Mattermost",
|
"mattermost": "💬 Mattermost",
|
||||||
"matrix": "💬 Matrix",
|
"matrix": "💬 Matrix",
|
||||||
"dingtalk": "💬 DingTalk",
|
"dingtalk": "💬 DingTalk",
|
||||||
|
"feishu": "🪽 Feishu",
|
||||||
}
|
}
|
||||||
|
|
||||||
# ─── Config Helpers ───────────────────────────────────────────────────────────
|
# ─── Config Helpers ───────────────────────────────────────────────────────────
|
||||||
|
|
|
||||||
|
|
@ -140,7 +140,8 @@ PLATFORMS = {
|
||||||
"homeassistant": {"label": "🏠 Home Assistant", "default_toolset": "hermes-homeassistant"},
|
"homeassistant": {"label": "🏠 Home Assistant", "default_toolset": "hermes-homeassistant"},
|
||||||
"email": {"label": "📧 Email", "default_toolset": "hermes-email"},
|
"email": {"label": "📧 Email", "default_toolset": "hermes-email"},
|
||||||
"matrix": {"label": "💬 Matrix", "default_toolset": "hermes-matrix"},
|
"matrix": {"label": "💬 Matrix", "default_toolset": "hermes-matrix"},
|
||||||
"dingtalk": {"label": "💬 DingTalk", "default_toolset": "hermes-dingtalk"},
|
"dingtalk": {"label": "💬 DingTalk", "default_toolset": "hermes-dingtalk"},
|
||||||
|
"feishu": {"label": "🪽 Feishu", "default_toolset": "hermes-feishu"},
|
||||||
"api_server": {"label": "🌐 API Server", "default_toolset": "hermes-api-server"},
|
"api_server": {"label": "🌐 API Server", "default_toolset": "hermes-api-server"},
|
||||||
"mattermost": {"label": "💬 Mattermost", "default_toolset": "hermes-mattermost"},
|
"mattermost": {"label": "💬 Mattermost", "default_toolset": "hermes-mattermost"},
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -58,6 +58,7 @@ homeassistant = ["aiohttp>=3.9.0,<4"]
|
||||||
sms = ["aiohttp>=3.9.0,<4"]
|
sms = ["aiohttp>=3.9.0,<4"]
|
||||||
acp = ["agent-client-protocol>=0.8.1,<0.9"]
|
acp = ["agent-client-protocol>=0.8.1,<0.9"]
|
||||||
dingtalk = ["dingtalk-stream>=0.1.0,<1"]
|
dingtalk = ["dingtalk-stream>=0.1.0,<1"]
|
||||||
|
feishu = ["lark-oapi>=1.5.3,<2"]
|
||||||
rl = [
|
rl = [
|
||||||
"atroposlib @ git+https://github.com/NousResearch/atropos.git",
|
"atroposlib @ git+https://github.com/NousResearch/atropos.git",
|
||||||
"tinker @ git+https://github.com/thinking-machines-lab/tinker.git",
|
"tinker @ git+https://github.com/thinking-machines-lab/tinker.git",
|
||||||
|
|
@ -83,6 +84,7 @@ all = [
|
||||||
"hermes-agent[acp]",
|
"hermes-agent[acp]",
|
||||||
"hermes-agent[voice]",
|
"hermes-agent[voice]",
|
||||||
"hermes-agent[dingtalk]",
|
"hermes-agent[dingtalk]",
|
||||||
|
"hermes-agent[feishu]",
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.scripts]
|
[project.scripts]
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,7 @@ def _would_warn():
|
||||||
"SIGNAL_ALLOWED_USERS", "SIGNAL_GROUP_ALLOWED_USERS",
|
"SIGNAL_ALLOWED_USERS", "SIGNAL_GROUP_ALLOWED_USERS",
|
||||||
"EMAIL_ALLOWED_USERS",
|
"EMAIL_ALLOWED_USERS",
|
||||||
"SMS_ALLOWED_USERS", "MATTERMOST_ALLOWED_USERS",
|
"SMS_ALLOWED_USERS", "MATTERMOST_ALLOWED_USERS",
|
||||||
"MATRIX_ALLOWED_USERS", "DINGTALK_ALLOWED_USERS",
|
"MATRIX_ALLOWED_USERS", "DINGTALK_ALLOWED_USERS", "FEISHU_ALLOWED_USERS",
|
||||||
"GATEWAY_ALLOWED_USERS")
|
"GATEWAY_ALLOWED_USERS")
|
||||||
)
|
)
|
||||||
_allow_all = os.getenv("GATEWAY_ALLOW_ALL_USERS", "").lower() in ("true", "1", "yes") or any(
|
_allow_all = os.getenv("GATEWAY_ALLOW_ALL_USERS", "").lower() in ("true", "1", "yes") or any(
|
||||||
|
|
@ -22,7 +22,7 @@ def _would_warn():
|
||||||
"WHATSAPP_ALLOW_ALL_USERS", "SLACK_ALLOW_ALL_USERS",
|
"WHATSAPP_ALLOW_ALL_USERS", "SLACK_ALLOW_ALL_USERS",
|
||||||
"SIGNAL_ALLOW_ALL_USERS", "EMAIL_ALLOW_ALL_USERS",
|
"SIGNAL_ALLOW_ALL_USERS", "EMAIL_ALLOW_ALL_USERS",
|
||||||
"SMS_ALLOW_ALL_USERS", "MATTERMOST_ALLOW_ALL_USERS",
|
"SMS_ALLOW_ALL_USERS", "MATTERMOST_ALLOW_ALL_USERS",
|
||||||
"MATRIX_ALLOW_ALL_USERS", "DINGTALK_ALLOW_ALL_USERS")
|
"MATRIX_ALLOW_ALL_USERS", "DINGTALK_ALLOW_ALL_USERS", "FEISHU_ALLOW_ALL_USERS")
|
||||||
)
|
)
|
||||||
return not _any_allowlist and not _allow_all
|
return not _any_allowlist and not _allow_all
|
||||||
|
|
||||||
|
|
|
||||||
2580
tests/gateway/test_feishu.py
Normal file
2580
tests/gateway/test_feishu.py
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -19,7 +19,7 @@ def _clear_auth_env(monkeypatch) -> None:
|
||||||
"SMS_ALLOWED_USERS",
|
"SMS_ALLOWED_USERS",
|
||||||
"MATTERMOST_ALLOWED_USERS",
|
"MATTERMOST_ALLOWED_USERS",
|
||||||
"MATRIX_ALLOWED_USERS",
|
"MATRIX_ALLOWED_USERS",
|
||||||
"DINGTALK_ALLOWED_USERS",
|
"DINGTALK_ALLOWED_USERS", "FEISHU_ALLOWED_USERS",
|
||||||
"GATEWAY_ALLOWED_USERS",
|
"GATEWAY_ALLOWED_USERS",
|
||||||
"TELEGRAM_ALLOW_ALL_USERS",
|
"TELEGRAM_ALLOW_ALL_USERS",
|
||||||
"DISCORD_ALLOW_ALL_USERS",
|
"DISCORD_ALLOW_ALL_USERS",
|
||||||
|
|
@ -30,7 +30,7 @@ def _clear_auth_env(monkeypatch) -> None:
|
||||||
"SMS_ALLOW_ALL_USERS",
|
"SMS_ALLOW_ALL_USERS",
|
||||||
"MATTERMOST_ALLOW_ALL_USERS",
|
"MATTERMOST_ALLOW_ALL_USERS",
|
||||||
"MATRIX_ALLOW_ALL_USERS",
|
"MATRIX_ALLOW_ALL_USERS",
|
||||||
"DINGTALK_ALLOW_ALL_USERS",
|
"DINGTALK_ALLOW_ALL_USERS", "FEISHU_ALLOW_ALL_USERS",
|
||||||
"GATEWAY_ALLOW_ALL_USERS",
|
"GATEWAY_ALLOW_ALL_USERS",
|
||||||
):
|
):
|
||||||
monkeypatch.delenv(key, raising=False)
|
monkeypatch.delenv(key, raising=False)
|
||||||
|
|
|
||||||
|
|
@ -372,7 +372,7 @@ Important safety rule: cron-run sessions should not recursively schedule more cr
|
||||||
},
|
},
|
||||||
"deliver": {
|
"deliver": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"description": "Delivery target: origin, local, telegram, discord, slack, whatsapp, signal, matrix, mattermost, homeassistant, dingtalk, email, sms, or platform:chat_id or platform:chat_id:thread_id for Telegram topics. Examples: 'origin', 'local', 'telegram', 'telegram:-1001234567890:17585', 'discord:#engineering'"
|
"description": "Delivery target: origin, local, telegram, discord, slack, whatsapp, signal, matrix, mattermost, homeassistant, dingtalk, feishu, email, sms, or platform:chat_id or platform:chat_id:thread_id for Telegram topics. Examples: 'origin', 'local', 'telegram', 'telegram:-1001234567890:17585', 'discord:#engineering'"
|
||||||
},
|
},
|
||||||
"model": {
|
"model": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ import time
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
_TELEGRAM_TOPIC_TARGET_RE = re.compile(r"^\s*(-?\d+)(?::(\d+))?\s*$")
|
_TELEGRAM_TOPIC_TARGET_RE = re.compile(r"^\s*(-?\d+)(?::(\d+))?\s*$")
|
||||||
|
_FEISHU_TARGET_RE = re.compile(r"^\s*((?:oc|ou|on|chat|open)_[-A-Za-z0-9]+)(?::([-A-Za-z0-9_]+))?\s*$")
|
||||||
_IMAGE_EXTS = {".jpg", ".jpeg", ".png", ".webp", ".gif"}
|
_IMAGE_EXTS = {".jpg", ".jpeg", ".png", ".webp", ".gif"}
|
||||||
_VIDEO_EXTS = {".mp4", ".mov", ".avi", ".mkv", ".3gp"}
|
_VIDEO_EXTS = {".mp4", ".mov", ".avi", ".mkv", ".3gp"}
|
||||||
_AUDIO_EXTS = {".ogg", ".opus", ".mp3", ".wav", ".m4a"}
|
_AUDIO_EXTS = {".ogg", ".opus", ".mp3", ".wav", ".m4a"}
|
||||||
|
|
@ -128,6 +129,7 @@ def _handle_send(args):
|
||||||
"mattermost": Platform.MATTERMOST,
|
"mattermost": Platform.MATTERMOST,
|
||||||
"homeassistant": Platform.HOMEASSISTANT,
|
"homeassistant": Platform.HOMEASSISTANT,
|
||||||
"dingtalk": Platform.DINGTALK,
|
"dingtalk": Platform.DINGTALK,
|
||||||
|
"feishu": Platform.FEISHU,
|
||||||
"email": Platform.EMAIL,
|
"email": Platform.EMAIL,
|
||||||
"sms": Platform.SMS,
|
"sms": Platform.SMS,
|
||||||
}
|
}
|
||||||
|
|
@ -198,6 +200,10 @@ def _parse_target_ref(platform_name: str, target_ref: str):
|
||||||
match = _TELEGRAM_TOPIC_TARGET_RE.fullmatch(target_ref)
|
match = _TELEGRAM_TOPIC_TARGET_RE.fullmatch(target_ref)
|
||||||
if match:
|
if match:
|
||||||
return match.group(1), match.group(2), True
|
return match.group(1), match.group(2), True
|
||||||
|
if platform_name == "feishu":
|
||||||
|
match = _FEISHU_TARGET_RE.fullmatch(target_ref)
|
||||||
|
if match:
|
||||||
|
return match.group(1), match.group(2), True
|
||||||
if target_ref.lstrip("-").isdigit():
|
if target_ref.lstrip("-").isdigit():
|
||||||
return target_ref, None, True
|
return target_ref, None, True
|
||||||
return None, None, False
|
return None, None, False
|
||||||
|
|
@ -280,6 +286,13 @@ async def _send_to_platform(platform, pconfig, chat_id, message, thread_id=None,
|
||||||
from gateway.platforms.discord import DiscordAdapter
|
from gateway.platforms.discord import DiscordAdapter
|
||||||
from gateway.platforms.slack import SlackAdapter
|
from gateway.platforms.slack import SlackAdapter
|
||||||
|
|
||||||
|
# Feishu adapter import is optional (requires lark-oapi)
|
||||||
|
try:
|
||||||
|
from gateway.platforms.feishu import FeishuAdapter
|
||||||
|
_feishu_available = True
|
||||||
|
except ImportError:
|
||||||
|
_feishu_available = False
|
||||||
|
|
||||||
media_files = media_files or []
|
media_files = media_files or []
|
||||||
|
|
||||||
# Platform message length limits (from adapter class attributes)
|
# Platform message length limits (from adapter class attributes)
|
||||||
|
|
@ -288,6 +301,8 @@ async def _send_to_platform(platform, pconfig, chat_id, message, thread_id=None,
|
||||||
Platform.DISCORD: DiscordAdapter.MAX_MESSAGE_LENGTH,
|
Platform.DISCORD: DiscordAdapter.MAX_MESSAGE_LENGTH,
|
||||||
Platform.SLACK: SlackAdapter.MAX_MESSAGE_LENGTH,
|
Platform.SLACK: SlackAdapter.MAX_MESSAGE_LENGTH,
|
||||||
}
|
}
|
||||||
|
if _feishu_available:
|
||||||
|
_MAX_LENGTHS[Platform.FEISHU] = FeishuAdapter.MAX_MESSAGE_LENGTH
|
||||||
|
|
||||||
# Smart-chunk the message to fit within platform limits.
|
# Smart-chunk the message to fit within platform limits.
|
||||||
# For short messages or platforms without a known limit this is a no-op.
|
# For short messages or platforms without a known limit this is a no-op.
|
||||||
|
|
@ -351,6 +366,8 @@ async def _send_to_platform(platform, pconfig, chat_id, message, thread_id=None,
|
||||||
result = await _send_homeassistant(pconfig.token, pconfig.extra, chat_id, chunk)
|
result = await _send_homeassistant(pconfig.token, pconfig.extra, chat_id, chunk)
|
||||||
elif platform == Platform.DINGTALK:
|
elif platform == Platform.DINGTALK:
|
||||||
result = await _send_dingtalk(pconfig.extra, chat_id, chunk)
|
result = await _send_dingtalk(pconfig.extra, chat_id, chunk)
|
||||||
|
elif platform == Platform.FEISHU:
|
||||||
|
result = await _send_feishu(pconfig, chat_id, chunk, thread_id=thread_id)
|
||||||
else:
|
else:
|
||||||
result = {"error": f"Direct sending not yet implemented for {platform.value}"}
|
result = {"error": f"Direct sending not yet implemented for {platform.value}"}
|
||||||
|
|
||||||
|
|
@ -777,6 +794,63 @@ async def _send_dingtalk(extra, chat_id, message):
|
||||||
return {"error": f"DingTalk send failed: {e}"}
|
return {"error": f"DingTalk send failed: {e}"}
|
||||||
|
|
||||||
|
|
||||||
|
async def _send_feishu(pconfig, chat_id, message, media_files=None, thread_id=None):
|
||||||
|
"""Send via Feishu/Lark using the adapter's send pipeline."""
|
||||||
|
try:
|
||||||
|
from gateway.platforms.feishu import FeishuAdapter, FEISHU_AVAILABLE
|
||||||
|
if not FEISHU_AVAILABLE:
|
||||||
|
return {"error": "Feishu dependencies not installed. Run: pip install 'hermes-agent[feishu]'"}
|
||||||
|
from gateway.platforms.feishu import FEISHU_DOMAIN, LARK_DOMAIN
|
||||||
|
except ImportError:
|
||||||
|
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 _IMAGE_EXTS:
|
||||||
|
last_result = await adapter.send_image_file(chat_id, media_path, metadata=metadata)
|
||||||
|
elif ext in _VIDEO_EXTS:
|
||||||
|
last_result = await adapter.send_video(chat_id, media_path, metadata=metadata)
|
||||||
|
elif ext in _VOICE_EXTS and is_voice:
|
||||||
|
last_result = await adapter.send_voice(chat_id, media_path, metadata=metadata)
|
||||||
|
elif ext in _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 _check_send_message():
|
def _check_send_message():
|
||||||
"""Gate send_message on gateway running (always available on messaging platforms)."""
|
"""Gate send_message on gateway running (always available on messaging platforms)."""
|
||||||
platform = os.getenv("HERMES_SESSION_PLATFORM", "")
|
platform = os.getenv("HERMES_SESSION_PLATFORM", "")
|
||||||
|
|
|
||||||
|
|
@ -351,6 +351,12 @@ TOOLSETS = {
|
||||||
"includes": []
|
"includes": []
|
||||||
},
|
},
|
||||||
|
|
||||||
|
"hermes-feishu": {
|
||||||
|
"description": "Feishu/Lark bot toolset - enterprise messaging via Feishu/Lark (full access)",
|
||||||
|
"tools": _HERMES_CORE_TOOLS,
|
||||||
|
"includes": []
|
||||||
|
},
|
||||||
|
|
||||||
"hermes-sms": {
|
"hermes-sms": {
|
||||||
"description": "SMS bot toolset - interact with Hermes via SMS (Twilio)",
|
"description": "SMS bot toolset - interact with Hermes via SMS (Twilio)",
|
||||||
"tools": _HERMES_CORE_TOOLS,
|
"tools": _HERMES_CORE_TOOLS,
|
||||||
|
|
@ -360,7 +366,7 @@ TOOLSETS = {
|
||||||
"hermes-gateway": {
|
"hermes-gateway": {
|
||||||
"description": "Gateway toolset - union of all messaging platform tools",
|
"description": "Gateway toolset - union of all messaging platform tools",
|
||||||
"tools": [],
|
"tools": [],
|
||||||
"includes": ["hermes-telegram", "hermes-discord", "hermes-whatsapp", "hermes-slack", "hermes-signal", "hermes-homeassistant", "hermes-email", "hermes-sms", "hermes-mattermost", "hermes-matrix", "hermes-dingtalk"]
|
"includes": ["hermes-telegram", "hermes-discord", "hermes-whatsapp", "hermes-slack", "hermes-signal", "hermes-homeassistant", "hermes-email", "hermes-sms", "hermes-mattermost", "hermes-matrix", "hermes-dingtalk", "hermes-feishu"]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,7 @@ Toolsets are named bundles of tools that you can enable with `hermes chat --tool
|
||||||
| `hermes-cli` | platform | `browser_back`, `browser_click`, `browser_close`, `browser_console`, `browser_get_images`, `browser_navigate`, `browser_press`, `browser_scroll`, `browser_snapshot`, `browser_type`, `browser_vision`, `clarify`, `cronjob`, `delegate_task`, `execute_code`, `ha_call_service`, `ha_get_state`, `ha_list_entities`, `ha_list_services`, `honcho_conclude`, `honcho_context`, `honcho_profile`, `honcho_search`, `image_generate`, `memory`, `mixture_of_agents`, `patch`, `process`, `read_file`, `search_files`, `send_message`, `session_search`, `skill_manage`, `skill_view`, `skills_list`, `terminal`, `text_to_speech`, `todo`, `vision_analyze`, `web_extract`, `web_search`, `write_file` |
|
| `hermes-cli` | platform | `browser_back`, `browser_click`, `browser_close`, `browser_console`, `browser_get_images`, `browser_navigate`, `browser_press`, `browser_scroll`, `browser_snapshot`, `browser_type`, `browser_vision`, `clarify`, `cronjob`, `delegate_task`, `execute_code`, `ha_call_service`, `ha_get_state`, `ha_list_entities`, `ha_list_services`, `honcho_conclude`, `honcho_context`, `honcho_profile`, `honcho_search`, `image_generate`, `memory`, `mixture_of_agents`, `patch`, `process`, `read_file`, `search_files`, `send_message`, `session_search`, `skill_manage`, `skill_view`, `skills_list`, `terminal`, `text_to_speech`, `todo`, `vision_analyze`, `web_extract`, `web_search`, `write_file` |
|
||||||
| `hermes-api-server` | platform | _(same as hermes-cli)_ |
|
| `hermes-api-server` | platform | _(same as hermes-cli)_ |
|
||||||
| `hermes-dingtalk` | platform | _(same as hermes-cli)_ |
|
| `hermes-dingtalk` | platform | _(same as hermes-cli)_ |
|
||||||
|
| `hermes-feishu` | platform | _(same as hermes-cli)_ |
|
||||||
| `hermes-discord` | platform | _(same as hermes-cli)_ |
|
| `hermes-discord` | platform | _(same as hermes-cli)_ |
|
||||||
| `hermes-email` | platform | _(same as hermes-cli)_ |
|
| `hermes-email` | platform | _(same as hermes-cli)_ |
|
||||||
| `hermes-gateway` | composite | Union of all messaging platform toolsets |
|
| `hermes-gateway` | composite | Union of all messaging platform toolsets |
|
||||||
|
|
|
||||||
129
website/docs/user-guide/messaging/feishu.md
Normal file
129
website/docs/user-guide/messaging/feishu.md
Normal file
|
|
@ -0,0 +1,129 @@
|
||||||
|
---
|
||||||
|
sidebar_position: 11
|
||||||
|
title: "Feishu / Lark"
|
||||||
|
description: "Set up Hermes Agent as a Feishu or Lark bot"
|
||||||
|
---
|
||||||
|
|
||||||
|
# Feishu / Lark Setup
|
||||||
|
|
||||||
|
Hermes Agent integrates with Feishu and Lark as a full-featured bot. Once connected, you can chat with the agent in direct messages or group chats, receive cron job results in a home chat, and send text, images, audio, and file attachments through the normal gateway flow.
|
||||||
|
|
||||||
|
The integration supports both connection modes:
|
||||||
|
|
||||||
|
- `websocket` — recommended; Hermes opens the outbound connection and you do not need a public webhook endpoint
|
||||||
|
- `webhook` — useful when you want Feishu/Lark to push events into your gateway over HTTP
|
||||||
|
|
||||||
|
## How Hermes Behaves
|
||||||
|
|
||||||
|
| Context | Behavior |
|
||||||
|
|---------|----------|
|
||||||
|
| Direct messages | Hermes responds to every message. |
|
||||||
|
| Group chats | Hermes responds when the bot is addressed in the chat. |
|
||||||
|
| Shared group chats | By default, session history is isolated per user inside a shared chat. |
|
||||||
|
|
||||||
|
This shared-chat behavior is controlled by `config.yaml`:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
group_sessions_per_user: true
|
||||||
|
```
|
||||||
|
|
||||||
|
Set it to `false` only if you explicitly want one shared conversation per chat.
|
||||||
|
|
||||||
|
## Step 1: Create a Feishu / Lark App
|
||||||
|
|
||||||
|
1. Open the Feishu or Lark developer console:
|
||||||
|
- Feishu: <https://open.feishu.cn/>
|
||||||
|
- Lark: <https://open.larksuite.com/>
|
||||||
|
2. Create a new app.
|
||||||
|
3. In **Credentials & Basic Info**, copy the **App ID** and **App Secret**.
|
||||||
|
4. Enable the **Bot** capability for the app.
|
||||||
|
|
||||||
|
:::warning
|
||||||
|
Keep the App Secret private. Anyone with it can impersonate your app.
|
||||||
|
:::
|
||||||
|
|
||||||
|
## Step 2: Choose a Connection Mode
|
||||||
|
|
||||||
|
### Recommended: WebSocket mode
|
||||||
|
|
||||||
|
Use WebSocket mode when Hermes runs on your laptop, workstation, or a private server. No public URL is required.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
FEISHU_CONNECTION_MODE=websocket
|
||||||
|
```
|
||||||
|
|
||||||
|
### Optional: Webhook mode
|
||||||
|
|
||||||
|
Use webhook mode only when you already run Hermes behind a reachable HTTP endpoint.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
FEISHU_CONNECTION_MODE=webhook
|
||||||
|
```
|
||||||
|
|
||||||
|
In webhook mode, Hermes serves a Feishu endpoint at:
|
||||||
|
|
||||||
|
```text
|
||||||
|
/feishu/webhook
|
||||||
|
```
|
||||||
|
|
||||||
|
## Step 3: Configure Hermes
|
||||||
|
|
||||||
|
### Option A: Interactive Setup
|
||||||
|
|
||||||
|
```bash
|
||||||
|
hermes gateway setup
|
||||||
|
```
|
||||||
|
|
||||||
|
Select **Feishu / Lark** and fill in the prompts.
|
||||||
|
|
||||||
|
### Option B: Manual Configuration
|
||||||
|
|
||||||
|
Add the following to `~/.hermes/.env`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
FEISHU_APP_ID=cli_xxx
|
||||||
|
FEISHU_APP_SECRET=secret_xxx
|
||||||
|
FEISHU_DOMAIN=feishu
|
||||||
|
FEISHU_CONNECTION_MODE=websocket
|
||||||
|
|
||||||
|
# Optional but strongly recommended
|
||||||
|
FEISHU_ALLOWED_USERS=ou_xxx,ou_yyy
|
||||||
|
FEISHU_HOME_CHANNEL=oc_xxx
|
||||||
|
```
|
||||||
|
|
||||||
|
`FEISHU_DOMAIN` accepts:
|
||||||
|
|
||||||
|
- `feishu` for Feishu China
|
||||||
|
- `lark` for Lark international
|
||||||
|
|
||||||
|
## Step 4: Start the Gateway
|
||||||
|
|
||||||
|
```bash
|
||||||
|
hermes gateway
|
||||||
|
```
|
||||||
|
|
||||||
|
Then message the bot from Feishu/Lark to confirm that the connection is live.
|
||||||
|
|
||||||
|
## Home Chat
|
||||||
|
|
||||||
|
Use `/set-home` in a Feishu/Lark chat to mark it as the home channel for cron job results and cross-platform notifications.
|
||||||
|
|
||||||
|
You can also preconfigure it:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
FEISHU_HOME_CHANNEL=oc_xxx
|
||||||
|
```
|
||||||
|
|
||||||
|
## Security
|
||||||
|
|
||||||
|
For production use, set an allowlist:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
FEISHU_ALLOWED_USERS=ou_xxx,ou_yyy
|
||||||
|
```
|
||||||
|
|
||||||
|
If you leave the allowlist empty, anyone who can reach the bot may be able to use it.
|
||||||
|
|
||||||
|
## Toolset
|
||||||
|
|
||||||
|
Feishu / Lark uses the `hermes-feishu` platform preset, which includes the same core tools as Telegram and other gateway-based messaging platforms.
|
||||||
|
|
@ -6,7 +6,7 @@ description: "Chat with Hermes from Telegram, Discord, Slack, WhatsApp, Signal,
|
||||||
|
|
||||||
# Messaging Gateway
|
# Messaging Gateway
|
||||||
|
|
||||||
Chat with Hermes from Telegram, Discord, Slack, WhatsApp, Signal, SMS, Email, Home Assistant, Mattermost, Matrix, DingTalk, or your browser. The gateway is a single background process that connects to all your configured platforms, handles sessions, runs cron jobs, and delivers voice messages.
|
Chat with Hermes from Telegram, Discord, Slack, WhatsApp, Signal, SMS, Email, Home Assistant, Mattermost, Matrix, DingTalk, Feishu/Lark, or your browser. The gateway is a single background process that connects to all your configured platforms, handles sessions, runs cron jobs, and delivers voice messages.
|
||||||
|
|
||||||
For the full voice feature set — including CLI microphone mode, spoken replies in messaging, and Discord voice-channel conversations — see [Voice Mode](/docs/user-guide/features/voice-mode) and [Use Voice Mode with Hermes](/docs/guides/use-voice-mode-with-hermes).
|
For the full voice feature set — including CLI microphone mode, spoken replies in messaging, and Discord voice-channel conversations — see [Voice Mode](/docs/user-guide/features/voice-mode) and [Use Voice Mode with Hermes](/docs/guides/use-voice-mode-with-hermes).
|
||||||
|
|
||||||
|
|
@ -27,6 +27,7 @@ flowchart TB
|
||||||
mm[Mattermost]
|
mm[Mattermost]
|
||||||
mx[Matrix]
|
mx[Matrix]
|
||||||
dt[DingTalk]
|
dt[DingTalk]
|
||||||
|
fs[Feishu/Lark]
|
||||||
api["API Server<br/>(OpenAI-compatible)"]
|
api["API Server<br/>(OpenAI-compatible)"]
|
||||||
wh[Webhooks]
|
wh[Webhooks]
|
||||||
end
|
end
|
||||||
|
|
@ -328,6 +329,7 @@ Each platform has its own toolset:
|
||||||
| Mattermost | `hermes-mattermost` | Full tools including terminal |
|
| Mattermost | `hermes-mattermost` | Full tools including terminal |
|
||||||
| Matrix | `hermes-matrix` | Full tools including terminal |
|
| Matrix | `hermes-matrix` | Full tools including terminal |
|
||||||
| DingTalk | `hermes-dingtalk` | Full tools including terminal |
|
| DingTalk | `hermes-dingtalk` | Full tools including terminal |
|
||||||
|
| Feishu/Lark | `hermes-feishu` | Full tools including terminal |
|
||||||
| API Server | `hermes` (default) | Full tools including terminal |
|
| API Server | `hermes` (default) | Full tools including terminal |
|
||||||
| Webhooks | `hermes-webhook` | Full tools including terminal |
|
| Webhooks | `hermes-webhook` | Full tools including terminal |
|
||||||
|
|
||||||
|
|
@ -344,5 +346,6 @@ Each platform has its own toolset:
|
||||||
- [Mattermost Setup](mattermost.md)
|
- [Mattermost Setup](mattermost.md)
|
||||||
- [Matrix Setup](matrix.md)
|
- [Matrix Setup](matrix.md)
|
||||||
- [DingTalk Setup](dingtalk.md)
|
- [DingTalk Setup](dingtalk.md)
|
||||||
|
- [Feishu/Lark Setup](feishu.md)
|
||||||
- [Open WebUI + API Server](open-webui.md)
|
- [Open WebUI + API Server](open-webui.md)
|
||||||
- [Webhooks](webhooks.md)
|
- [Webhooks](webhooks.md)
|
||||||
|
|
|
||||||
|
|
@ -54,6 +54,7 @@ const sidebars: SidebarsConfig = {
|
||||||
'user-guide/messaging/mattermost',
|
'user-guide/messaging/mattermost',
|
||||||
'user-guide/messaging/matrix',
|
'user-guide/messaging/matrix',
|
||||||
'user-guide/messaging/dingtalk',
|
'user-guide/messaging/dingtalk',
|
||||||
|
'user-guide/messaging/feishu',
|
||||||
'user-guide/messaging/open-webui',
|
'user-guide/messaging/open-webui',
|
||||||
'user-guide/messaging/webhooks',
|
'user-guide/messaging/webhooks',
|
||||||
],
|
],
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue