diff --git a/agent/prompt_builder.py b/agent/prompt_builder.py index 2a210434949b..1f972dcf9362 100644 --- a/agent/prompt_builder.py +++ b/agent/prompt_builder.py @@ -310,6 +310,11 @@ def _strip_yaml_frontmatter(content: str) -> str: "bubbles, and videos (.mp4) play inline. You can also include image " "URLs in markdown format ![alt](url) and they will be sent as native photos." ), + "line": ( + "You are on a text messaging communication platform, LINE. " + "Prefer concise plain text. LINE does not render markdown reliably, " + "so avoid markdown-specific formatting unless the user asks for it." + ), "discord": ( "You are in a Discord server or group chat communicating with your user. " "You can send media files natively: include MEDIA:/absolute/path/to/file " diff --git a/cron/scheduler.py b/cron/scheduler.py index ebeb29dd4171..3523200b2833 100644 --- a/cron/scheduler.py +++ b/cron/scheduler.py @@ -289,6 +289,7 @@ def _deliver_result(job: dict, content: str, adapters=None, loop=None) -> Option "discord": Platform.DISCORD, "slack": Platform.SLACK, "whatsapp": Platform.WHATSAPP, + "line": Platform.LINE, "signal": Platform.SIGNAL, "matrix": Platform.MATRIX, "mattermost": Platform.MATTERMOST, diff --git a/gateway/config.py b/gateway/config.py index 2d74073234ef..8e2640d67cba 100644 --- a/gateway/config.py +++ b/gateway/config.py @@ -66,6 +66,7 @@ class Platform(Enum): WECOM_CALLBACK = "wecom_callback" WEIXIN = "weixin" BLUEBUBBLES = "bluebubbles" + LINE = "line" QQBOT = "qqbot" @@ -276,6 +277,11 @@ def get_connected_platforms(self) -> List[Platform]: if config.extra.get("account_id") and (config.token or config.extra.get("token")): connected.append(platform) continue + # LINE requires both the channel access token and channel secret. + if platform == Platform.LINE: + if (config.token or config.extra.get("channel_access_token")) and config.extra.get("channel_secret"): + connected.append(platform) + continue # Platforms that use token/api_key auth if config.token or config.api_key: connected.append(platform) @@ -783,6 +789,7 @@ def _validate_gateway_config(config: "GatewayConfig") -> None: Platform.MATTERMOST: "MATTERMOST_TOKEN", Platform.MATRIX: "MATRIX_ACCESS_TOKEN", Platform.WEIXIN: "WEIXIN_TOKEN", + Platform.LINE: "LINE_CHANNEL_ACCESS_TOKEN", } for platform, pconfig in config.platforms.items(): if not pconfig.enabled: @@ -855,6 +862,42 @@ def _apply_env_overrides(config: GatewayConfig) -> None: chat_id=telegram_home, name=os.getenv("TELEGRAM_HOME_CHANNEL_NAME", "Home"), ) + + # LINE Messaging API + line_token = os.getenv("LINE_CHANNEL_ACCESS_TOKEN") + line_secret = os.getenv("LINE_CHANNEL_SECRET") + line_api_base_url = os.getenv("LINE_API_BASE_URL") + line_webhook_host = os.getenv("LINE_WEBHOOK_HOST") + line_webhook_port = os.getenv("LINE_WEBHOOK_PORT") + line_webhook_path = os.getenv("LINE_WEBHOOK_PATH") + if any([line_token, line_secret, line_api_base_url, line_webhook_host, line_webhook_port, line_webhook_path]): + if Platform.LINE not in config.platforms: + config.platforms[Platform.LINE] = PlatformConfig() + line_config = config.platforms[Platform.LINE] + line_config.enabled = True + if line_token: + line_config.token = line_token + if line_secret: + line_config.extra["channel_secret"] = line_secret + if line_api_base_url: + line_config.extra["api_base_url"] = line_api_base_url + if line_webhook_host: + line_config.extra["webhook_host"] = line_webhook_host + if line_webhook_path: + line_config.extra["webhook_path"] = line_webhook_path + if line_webhook_port: + try: + line_config.extra["webhook_port"] = int(line_webhook_port) + except ValueError: + pass + + line_home = os.getenv("LINE_HOME_CHANNEL") + if line_home and Platform.LINE in config.platforms: + config.platforms[Platform.LINE].home_channel = HomeChannel( + platform=Platform.LINE, + chat_id=line_home, + name=os.getenv("LINE_HOME_CHANNEL_NAME", "Home"), + ) # Discord discord_token = os.getenv("DISCORD_BOT_TOKEN") diff --git a/gateway/platforms/line.py b/gateway/platforms/line.py new file mode 100644 index 000000000000..99bce9e257d7 --- /dev/null +++ b/gateway/platforms/line.py @@ -0,0 +1,253 @@ +"""LINE Messaging API platform adapter.""" + +import base64 +import hashlib +import hmac +import json +import logging +import os +from typing import Any, Dict, Optional + +import httpx + +try: + from aiohttp import web + AIOHTTP_AVAILABLE = True +except ImportError: + AIOHTTP_AVAILABLE = False + web = None # type: ignore[assignment] + +from gateway.config import Platform, PlatformConfig +from gateway.platforms.base import BasePlatformAdapter, MessageEvent, MessageType, SendResult + +logger = logging.getLogger(__name__) + +DEFAULT_WEBHOOK_HOST = "0.0.0.0" +DEFAULT_WEBHOOK_PORT = 18789 +DEFAULT_WEBHOOK_PATH = "/line/webhook" +DEFAULT_LINE_API_BASE_URL = "https://api.line.me" + + +def check_line_requirements() -> bool: + """Check if LINE webhook dependencies are available.""" + return AIOHTTP_AVAILABLE + + +class LineAdapter(BasePlatformAdapter): + """LINE Messaging API adapter using webhooks for inbound events.""" + + MAX_MESSAGE_LENGTH = 5000 + + def __init__(self, config: PlatformConfig): + super().__init__(config, Platform.LINE) + extra = config.extra or {} + self._channel_access_token = ( + config.token + or extra.get("channel_access_token") + or os.getenv("LINE_CHANNEL_ACCESS_TOKEN", "") + ) + self._channel_secret = extra.get("channel_secret") or os.getenv("LINE_CHANNEL_SECRET", "") + self._api_base_url = ( + extra.get("api_base_url") + or os.getenv("LINE_API_BASE_URL") + or DEFAULT_LINE_API_BASE_URL + ).rstrip("/") + self._host = extra.get("webhook_host") or os.getenv("LINE_WEBHOOK_HOST", DEFAULT_WEBHOOK_HOST) + self._port = int(extra.get("webhook_port") or os.getenv("LINE_WEBHOOK_PORT", DEFAULT_WEBHOOK_PORT)) + self._path = extra.get("webhook_path") or os.getenv("LINE_WEBHOOK_PATH", DEFAULT_WEBHOOK_PATH) + if not self._path.startswith("/"): + self._path = f"/{self._path}" + + self._app: Optional["web.Application"] = None + self._runner: Optional["web.AppRunner"] = None + self._site: Optional["web.TCPSite"] = None + self._client: Optional[httpx.AsyncClient] = None + + async def connect(self) -> bool: + if not AIOHTTP_AVAILABLE: + self._set_fatal_error("line_missing_aiohttp", "aiohttp is not installed", retryable=False) + return False + if not self._channel_access_token: + self._set_fatal_error( + "line_missing_token", + "LINE_CHANNEL_ACCESS_TOKEN is required", + retryable=False, + ) + return False + if not self._channel_secret: + self._set_fatal_error( + "line_missing_secret", + "LINE_CHANNEL_SECRET is required", + retryable=False, + ) + return False + + try: + self._client = httpx.AsyncClient(timeout=30.0) + self._app = web.Application() + self._app.router.add_get("/health", self._health) + self._app.router.add_post(self._path, self._handle_webhook) + self._runner = web.AppRunner(self._app) + await self._runner.setup() + self._site = web.TCPSite(self._runner, self._host, self._port) + await self._site.start() + self._mark_connected() + logger.info("[%s] LINE webhook listening on %s:%s%s", self.name, self._host, self._port, self._path) + return True + except Exception as exc: + await self.disconnect() + self._set_fatal_error("line_webhook_start_failed", str(exc), retryable=True) + return False + + async def disconnect(self) -> None: + if self._client: + await self._client.aclose() + self._client = None + if self._runner: + await self._runner.cleanup() + self._runner = None + self._site = None + self._app = None + self._mark_disconnected() + + async def send( + self, + chat_id: str, + content: str, + reply_to: Optional[str] = None, + metadata: Optional[Dict[str, Any]] = None, + ) -> SendResult: + if not self._client: + return SendResult(success=False, error="Not connected") + + chunks = self.truncate_message(content, self.MAX_MESSAGE_LENGTH) + last_message_id = None + for chunk in chunks: + try: + response = await self._client.post( + f"{self._api_base_url}/v2/bot/message/push", + headers={ + "Authorization": f"Bearer {self._channel_access_token}", + "Content-Type": "application/json", + }, + json={ + "to": chat_id, + "messages": [{"type": "text", "text": chunk}], + }, + ) + if response.status_code >= 400: + return SendResult( + success=False, + error=f"LINE API error ({response.status_code}): {response.text}", + ) + data = response.json() if response.content else {} + sent_messages = data.get("sentMessages") or [] + if sent_messages: + last_message_id = sent_messages[0].get("id") + except httpx.ConnectError as exc: + return SendResult(success=False, error=str(exc), retryable=True) + except Exception as exc: + return SendResult(success=False, error=str(exc)) + + return SendResult(success=True, message_id=last_message_id) + + async def send_typing(self, chat_id: str, metadata=None) -> None: + if not self._client: + return + try: + await self._client.post( + f"{self._api_base_url}/v2/bot/chat/loading/start", + headers={ + "Authorization": f"Bearer {self._channel_access_token}", + "Content-Type": "application/json", + }, + json={"chatId": chat_id, "loadingSeconds": 5}, + ) + except Exception: + pass + + async def get_chat_info(self, chat_id: str) -> Dict[str, Any]: + return {"id": chat_id, "name": chat_id, "type": "dm"} + + async def _health(self, request: "web.Request") -> "web.Response": + return web.json_response({"ok": True, "platform": "line"}) + + async def _handle_webhook(self, request: "web.Request") -> "web.Response": + body = await request.read() + if not self._verify_signature(request.headers.get("x-line-signature", ""), body): + return web.json_response({"ok": False, "error": "Invalid signature"}, status=401) + + try: + payload = json.loads(body.decode("utf-8")) + except json.JSONDecodeError: + return web.json_response({"ok": False, "error": "Invalid JSON"}, status=400) + + for event in payload.get("events", []): + message_event = self._to_message_event(event) + if message_event: + await self.handle_message(message_event) + + return web.json_response({"ok": True}) + + def _verify_signature(self, signature: str, body: bytes) -> bool: + if not signature: + return False + expected = base64.b64encode( + hmac.new(self._channel_secret.encode("utf-8"), body, hashlib.sha256).digest() + ).decode("ascii") + return hmac.compare_digest(signature, expected) + + def _to_message_event(self, event: Dict[str, Any]) -> Optional[MessageEvent]: + if event.get("type") != "message": + return None + + source = event.get("source") or {} + source_type = source.get("type", "user") + user_id = source.get("userId") + chat_id = self._chat_id_from_source(source) + if source_type != "user": + logger.info("[%s] Ignoring LINE %s message without direct user context", self.name, source_type) + return None + if not user_id or not chat_id: + logger.info("[%s] Ignoring LINE message with incomplete user context", self.name) + return None + + line_message = event.get("message") or {} + message_type = line_message.get("type", "") + text = line_message.get("text") if message_type == "text" else f"[LINE {message_type} message]" + + return MessageEvent( + text=text, + message_type=self._message_type(message_type), + source=self.build_source( + chat_id=chat_id, + chat_name=chat_id, + chat_type="dm" if source_type == "user" else "group", + user_id=user_id, + user_name=user_id, + ), + raw_message=event, + message_id=line_message.get("id") or event.get("webhookEventId"), + ) + + @staticmethod + def _chat_id_from_source(source: Dict[str, Any]) -> Optional[str]: + source_type = source.get("type") + if source_type == "user": + return source.get("userId") + if source_type == "group": + return source.get("groupId") + if source_type == "room": + return source.get("roomId") + return None + + @staticmethod + def _message_type(line_type: str) -> MessageType: + return { + "text": MessageType.TEXT, + "image": MessageType.PHOTO, + "video": MessageType.VIDEO, + "audio": MessageType.AUDIO, + "file": MessageType.DOCUMENT, + "sticker": MessageType.STICKER, + }.get(line_type, MessageType.TEXT) diff --git a/gateway/run.py b/gateway/run.py index eb0dfe237ff6..6bc21ca46422 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -2715,6 +2715,13 @@ def _create_adapter( logger.warning("WhatsApp: Node.js not installed or bridge not configured") return None return WhatsAppAdapter(config) + + elif platform == Platform.LINE: + from gateway.platforms.line import LineAdapter, check_line_requirements + if not check_line_requirements(): + logger.warning("LINE: aiohttp not installed") + return None + return LineAdapter(config) elif platform == Platform.SLACK: from gateway.platforms.slack import SlackAdapter, check_slack_requirements @@ -2862,6 +2869,7 @@ def _is_user_authorized(self, source: SessionSource) -> bool: Platform.TELEGRAM: "TELEGRAM_ALLOWED_USERS", Platform.DISCORD: "DISCORD_ALLOWED_USERS", Platform.WHATSAPP: "WHATSAPP_ALLOWED_USERS", + Platform.LINE: "LINE_ALLOWED_USERS", Platform.SLACK: "SLACK_ALLOWED_USERS", Platform.SIGNAL: "SIGNAL_ALLOWED_USERS", Platform.EMAIL: "EMAIL_ALLOWED_USERS", @@ -2883,6 +2891,7 @@ def _is_user_authorized(self, source: SessionSource) -> bool: Platform.TELEGRAM: "TELEGRAM_ALLOW_ALL_USERS", Platform.DISCORD: "DISCORD_ALLOW_ALL_USERS", Platform.WHATSAPP: "WHATSAPP_ALLOW_ALL_USERS", + Platform.LINE: "LINE_ALLOW_ALL_USERS", Platform.SLACK: "SLACK_ALLOW_ALL_USERS", Platform.SIGNAL: "SIGNAL_ALLOW_ALL_USERS", Platform.EMAIL: "EMAIL_ALLOW_ALL_USERS", @@ -7541,7 +7550,7 @@ async def _handle_deny_command(self, event: MessageEvent) -> str: # Platforms where /update is allowed. ACP, API server, and webhooks are # programmatic interfaces that should not trigger system updates. _UPDATE_ALLOWED_PLATFORMS = frozenset({ - Platform.TELEGRAM, Platform.DISCORD, Platform.SLACK, Platform.WHATSAPP, + Platform.TELEGRAM, Platform.DISCORD, Platform.SLACK, Platform.WHATSAPP, Platform.LINE, Platform.SIGNAL, Platform.MATTERMOST, Platform.MATRIX, Platform.HOMEASSISTANT, Platform.EMAIL, Platform.SMS, Platform.DINGTALK, Platform.FEISHU, Platform.WECOM, Platform.WECOM_CALLBACK, Platform.WEIXIN, Platform.BLUEBUBBLES, Platform.QQBOT, Platform.LOCAL, diff --git a/hermes_cli/models.py b/hermes_cli/models.py index 6413c35fdfe7..afe3018675e7 100644 --- a/hermes_cli/models.py +++ b/hermes_cli/models.py @@ -133,6 +133,8 @@ def _codex_curated_models() -> list[str]: "grok-code-fast-1", ], "gemini": [ + "gemini-2.5-pro", + "gemini-2.5-flash", "gemini-3.1-pro-preview", "gemini-3-pro-preview", "gemini-3-flash-preview", diff --git a/hermes_cli/platforms.py b/hermes_cli/platforms.py index 1fc3a3a85011..b289abd7801e 100644 --- a/hermes_cli/platforms.py +++ b/hermes_cli/platforms.py @@ -24,6 +24,7 @@ class PlatformInfo(NamedTuple): ("discord", PlatformInfo(label="💬 Discord", default_toolset="hermes-discord")), ("slack", PlatformInfo(label="💼 Slack", default_toolset="hermes-slack")), ("whatsapp", PlatformInfo(label="📱 WhatsApp", default_toolset="hermes-whatsapp")), + ("line", PlatformInfo(label="LINE", default_toolset="hermes-line")), ("signal", PlatformInfo(label="📡 Signal", default_toolset="hermes-signal")), ("bluebubbles", PlatformInfo(label="💙 BlueBubbles", default_toolset="hermes-bluebubbles")), ("email", PlatformInfo(label="📧 Email", default_toolset="hermes-email")), diff --git a/nix/web.nix b/nix/web.nix index 247889753f67..c9aae7531b2e 100644 --- a/nix/web.nix +++ b/nix/web.nix @@ -4,7 +4,7 @@ let src = ../web; npmDeps = pkgs.fetchNpmDeps { inherit src; - hash = "sha256-Y0pOzdFG8BLjfvCLmsvqYpjxFjAQabXp1i7X9W/cCU4="; + hash = "sha256-Kh7lX3iWMM25E2kotpJFouZxtAx0n0fOsHeyPllqDDg="; }; npmLockHash = builtins.hashString "sha256" (builtins.readFile ../web/package-lock.json); diff --git a/run_agent.py b/run_agent.py index a1e3e3038b7a..61c97649ec52 100644 --- a/run_agent.py +++ b/run_agent.py @@ -2360,7 +2360,9 @@ def _resolved_api_call_stale_timeout_base(self) -> tuple[float, bool]: explicitly configured a stale timeout, such as auto-disabling the detector for local endpoints. """ - cfg = get_provider_stale_timeout(self.provider, self.model) + provider = getattr(self, "provider", None) + model = getattr(self, "model", None) + cfg = get_provider_stale_timeout(provider, model) if provider else None if cfg is not None: return cfg, False @@ -2373,7 +2375,7 @@ def _resolved_api_call_stale_timeout_base(self) -> tuple[float, bool]: def _compute_non_stream_stale_timeout(self, messages: list[dict[str, Any]]) -> float: """Compute the effective non-stream stale timeout for this request.""" stale_base, uses_implicit_default = self._resolved_api_call_stale_timeout_base() - base_url = getattr(self, "_base_url", None) or self.base_url or "" + base_url = getattr(self, "_base_url", None) or "" if uses_implicit_default and base_url and is_local_endpoint(base_url): return float("inf") diff --git a/scripts/release.py b/scripts/release.py index 56ff878f550d..98295b200e15 100755 --- a/scripts/release.py +++ b/scripts/release.py @@ -158,6 +158,7 @@ "openclaw@sparklab.ai": "openclaw", "semihcvlk53@gmail.com": "Himess", "erenkar950@gmail.com": "erenkarakus", + "0xde@pieverse.io": "plkpie", "adavyasharma@gmail.com": "adavyas", "acaayush1111@gmail.com": "aayushchaudhary", "jason@outland.art": "jasonoutland", diff --git a/tests/agent/test_insights.py b/tests/agent/test_insights.py index 4067c9215705..7a037dfeadbd 100644 --- a/tests/agent/test_insights.py +++ b/tests/agent/test_insights.py @@ -520,9 +520,9 @@ def test_gateway_format_hides_cost(self, populated_db): report = engine.generate(days=30) text = engine.format_gateway(report) - assert "$" in text assert "Top Skills" in text - assert "Est. cost" in text + assert "$" not in text + assert "Est. cost" not in text assert "cache" not in text.lower() def test_gateway_format_shows_models(self, populated_db): diff --git a/tests/agent/test_prompt_builder.py b/tests/agent/test_prompt_builder.py index 0962060313bb..b7154777b149 100644 --- a/tests/agent/test_prompt_builder.py +++ b/tests/agent/test_prompt_builder.py @@ -785,6 +785,7 @@ def test_default_identity_non_empty(self): def test_platform_hints_known_platforms(self): assert "whatsapp" in PLATFORM_HINTS assert "telegram" in PLATFORM_HINTS + assert "line" in PLATFORM_HINTS assert "discord" in PLATFORM_HINTS assert "cron" in PLATFORM_HINTS assert "cli" in PLATFORM_HINTS diff --git a/tests/gateway/test_line.py b/tests/gateway/test_line.py new file mode 100644 index 000000000000..8d4badd667ae --- /dev/null +++ b/tests/gateway/test_line.py @@ -0,0 +1,291 @@ +"""Tests for LINE platform integration.""" + +import asyncio +import base64 +import hashlib +import hmac +import os +import json +import logging +from types import SimpleNamespace +from unittest.mock import AsyncMock, MagicMock, patch + +import httpx +import pytest +pytest.importorskip("aiohttp") +from aiohttp import web +from aiohttp.test_utils import TestClient, TestServer + +from gateway.config import GatewayConfig, Platform, PlatformConfig, _apply_env_overrides +from gateway.platforms.line import LineAdapter +from gateway.platforms.base import MessageType + + +def _signature(secret: str, body: bytes) -> str: + return base64.b64encode(hmac.new(secret.encode("utf-8"), body, hashlib.sha256).digest()).decode("ascii") + + +def _create_app(adapter: LineAdapter) -> web.Application: + app = web.Application() + app.router.add_get("/health", adapter._health) + app.router.add_post(adapter._path, adapter._handle_webhook) + return app + + +class TestLineConfig: + def test_line_platform_enum_exists(self): + assert Platform.LINE.value == "line" + + def test_env_overrides_create_line_config(self): + config = GatewayConfig() + env = { + "LINE_CHANNEL_ACCESS_TOKEN": "line-token", + "LINE_CHANNEL_SECRET": "line-secret", + "LINE_API_BASE_URL": "http://channel-gateway/line", + "LINE_WEBHOOK_HOST": "0.0.0.0", + "LINE_WEBHOOK_PORT": "18789", + "LINE_WEBHOOK_PATH": "/line/webhook", + "LINE_HOME_CHANNEL": "Uhome", + } + + with patch.dict(os.environ, env, clear=False): + _apply_env_overrides(config) + + pc = config.platforms[Platform.LINE] + assert pc.enabled is True + assert pc.token == "line-token" + assert pc.extra["channel_secret"] == "line-secret" + assert pc.extra["api_base_url"] == "http://channel-gateway/line" + assert pc.extra["webhook_port"] == 18789 + assert pc.home_channel.chat_id == "Uhome" + + def test_connected_platforms_requires_token_and_secret(self): + missing_secret = GatewayConfig( + platforms={Platform.LINE: PlatformConfig(enabled=True, token="line-token")} + ) + configured = GatewayConfig( + platforms={ + Platform.LINE: PlatformConfig( + enabled=True, + token="line-token", + extra={"channel_secret": "line-secret"}, + ) + } + ) + + assert Platform.LINE not in missing_secret.get_connected_platforms() + assert Platform.LINE in configured.get_connected_platforms() + + +class TestLineAdapter: + def test_verify_signature_accepts_valid_line_hmac(self): + adapter = LineAdapter( + PlatformConfig(enabled=True, token="line-token", extra={"channel_secret": "line-secret"}) + ) + body = b'{"events":[]}' + + assert adapter._verify_signature(_signature("line-secret", body), body) is True + assert adapter._verify_signature(_signature("wrong-secret", body), body) is False + + def test_message_event_from_text_webhook_event(self): + adapter = LineAdapter( + PlatformConfig(enabled=True, token="line-token", extra={"channel_secret": "line-secret"}) + ) + + event = adapter._to_message_event( + { + "type": "message", + "webhookEventId": "evt-1", + "source": {"type": "user", "userId": "U123"}, + "message": {"id": "msg-1", "type": "text", "text": "hello"}, + } + ) + + assert event is not None + assert event.text == "hello" + assert event.message_type == MessageType.TEXT + assert event.source.platform == Platform.LINE + assert event.source.chat_id == "U123" + assert event.source.user_id == "U123" + assert event.message_id == "msg-1" + + def test_group_message_is_ignored_with_log(self, caplog): + adapter = LineAdapter( + PlatformConfig(enabled=True, token="line-token", extra={"channel_secret": "line-secret"}) + ) + + with caplog.at_level(logging.INFO): + event = adapter._to_message_event( + { + "type": "message", + "webhookEventId": "evt-group", + "source": {"type": "group", "groupId": "G123"}, + "message": {"id": "msg-1", "type": "text", "text": "hello"}, + } + ) + + assert event is None + assert any("without direct user context" in record.message for record in caplog.records) + + @pytest.mark.asyncio + async def test_webhook_http_signature_smoke(self): + adapter = LineAdapter( + PlatformConfig(enabled=True, token="line-token", extra={"channel_secret": "line-secret"}) + ) + captured = [] + + async def _capture(event): + captured.append(event) + + adapter.handle_message = _capture + body = json.dumps( + { + "events": [ + { + "type": "message", + "webhookEventId": "evt-1", + "source": {"type": "user", "userId": "U123"}, + "message": {"id": "msg-1", "type": "text", "text": "hello"}, + } + ] + } + ).encode("utf-8") + + async with TestClient(TestServer(_create_app(adapter))) as cli: + bad = await cli.post( + "/line/webhook", + data=body, + headers={"Content-Type": "application/json", "x-line-signature": "bad-signature"}, + ) + assert bad.status == 401 + + good = await cli.post( + "/line/webhook", + data=body, + headers={ + "Content-Type": "application/json", + "x-line-signature": _signature("line-secret", body), + }, + ) + assert good.status == 200 + assert await good.json() == {"ok": True} + + assert len(captured) == 1 + assert captured[0].text == "hello" + + @pytest.mark.asyncio + async def test_send_smoke_uses_fake_line_api_server(self): + pushed = [] + + async def _push(request): + pushed.append( + { + "authorization": request.headers.get("Authorization"), + "json": await request.json(), + } + ) + return web.json_response({"sentMessages": [{"id": "msg-out"}]}) + + app = web.Application() + app.router.add_post("/line/v2/bot/message/push", _push) + + async with TestClient(TestServer(app)) as cli: + adapter = LineAdapter( + PlatformConfig( + enabled=True, + token="line-token", + extra={ + "channel_secret": "line-secret", + "api_base_url": str(cli.make_url("/line")).rstrip("/"), + }, + ) + ) + adapter._client = httpx.AsyncClient(timeout=30.0) + + try: + result = await adapter.send("U123", "hello") + finally: + await adapter._client.aclose() + adapter._client = None + + assert result.success is True + assert result.message_id == "msg-out" + assert pushed == [ + { + "authorization": "Bearer line-token", + "json": {"to": "U123", "messages": [{"type": "text", "text": "hello"}]}, + } + ] + + @pytest.mark.asyncio + async def test_send_uses_configured_line_api_base_url(self): + adapter = LineAdapter( + PlatformConfig( + enabled=True, + token="line-token", + extra={ + "channel_secret": "line-secret", + "api_base_url": "http://channel-gateway/line", + }, + ) + ) + calls = [] + + class FakeClient: + async def post(self, url, headers=None, json=None): + calls.append({"url": url, "headers": headers, "json": json}) + return SimpleNamespace( + status_code=200, + content=b"{}", + json=lambda: {"sentMessages": [{"id": "msg-out"}]}, + ) + + adapter._client = FakeClient() + + result = await adapter.send("U123", "hello") + + assert result.success is True + assert result.message_id == "msg-out" + assert calls[0]["url"] == "http://channel-gateway/line/v2/bot/message/push" + assert calls[0]["headers"]["Authorization"] == "Bearer line-token" + assert calls[0]["json"] == {"to": "U123", "messages": [{"type": "text", "text": "hello"}]} + + +class TestLineAuthorization: + def test_line_allowed_users_authorizes_sender(self): + from gateway.run import GatewayRunner + + gw = GatewayRunner.__new__(GatewayRunner) + gw.config = GatewayConfig() + gw.pairing_store = MagicMock() + gw.pairing_store.is_approved.return_value = False + + source = SimpleNamespace( + platform=Platform.LINE, + user_id="U123", + user_name="U123", + chat_id="U123", + chat_type="dm", + ) + + with patch.dict(os.environ, {"LINE_ALLOWED_USERS": "U123"}, clear=True): + assert gw._is_user_authorized(source) is True + + +class TestLineSendMessage: + def test_line_routes_via_sender(self): + from tools.send_message_tool import _send_to_platform + + send = AsyncMock(return_value={"success": True, "platform": "line", "chat_id": "U123"}) + with patch("tools.send_message_tool._send_line", send): + result = asyncio.run( + _send_to_platform( + Platform.LINE, + SimpleNamespace(enabled=True, token="line-token", extra={}), + "U123", + "hello from hermes", + ) + ) + + assert result["success"] is True + send.assert_awaited_once() diff --git a/tests/hermes_cli/test_config.py b/tests/hermes_cli/test_config.py index 8c94902e68cd..aba86c327191 100644 --- a/tests/hermes_cli/test_config.py +++ b/tests/hermes_cli/test_config.py @@ -501,7 +501,7 @@ def test_providers_dict_resolves_at_runtime(self, tmp_path): assert compatible[0]["provider_key"] == "openai-direct" assert compatible[0]["api_mode"] == "codex_responses" - def test_compatible_custom_providers_prefers_api_then_url_then_base_url(self, tmp_path): + def test_compatible_custom_providers_prefers_base_url_then_url_then_api(self, tmp_path): config_path = tmp_path / "config.yaml" config_path.write_text( yaml.safe_dump( @@ -526,7 +526,7 @@ def test_compatible_custom_providers_prefers_api_then_url_then_base_url(self, tm assert compatible == [ { "name": "My Provider", - "base_url": "https://api.example.com/v1", + "base_url": "https://base.example.com/v1", "provider_key": "my-provider", } ] diff --git a/tests/test_toolsets.py b/tests/test_toolsets.py index 183f9e514f16..975604160c34 100644 --- a/tests/test_toolsets.py +++ b/tests/test_toolsets.py @@ -204,7 +204,7 @@ def test_hermes_platforms_share_core_tools(self): hermes-discord, gated on DISCORD_BOT_TOKEN) are allowed on top — the invariant is that the core set is identical across platforms. """ - platforms = ["hermes-cli", "hermes-telegram", "hermes-discord", "hermes-whatsapp", "hermes-slack", "hermes-signal", "hermes-homeassistant"] + platforms = ["hermes-cli", "hermes-telegram", "hermes-discord", "hermes-whatsapp", "hermes-line", "hermes-slack", "hermes-signal", "hermes-homeassistant"] tool_sets = [set(TOOLSETS[p]["tools"]) for p in platforms] # All platforms must contain the shared core; platform-specific # extras are OK (subset check, not equality). diff --git a/tests/test_transform_tool_result_hook.py b/tests/test_transform_tool_result_hook.py index 159446fd57db..64e9ac5e93b0 100644 --- a/tests/test_transform_tool_result_hook.py +++ b/tests/test_transform_tool_result_hook.py @@ -165,6 +165,10 @@ def test_transform_tool_result_integration_with_real_plugin(monkeypatch, tmp_pat plugins_dir = hermes_home / "plugins" plugin_dir = plugins_dir / "transform_result_canon" plugin_dir.mkdir(parents=True) + (hermes_home / "config.yaml").write_text( + "plugins:\n enabled:\n - transform_result_canon\n", + encoding="utf-8", + ) (plugin_dir / "plugin.yaml").write_text("name: transform_result_canon\n", encoding="utf-8") (plugin_dir / "__init__.py").write_text( "def register(ctx):\n" diff --git a/tests/tools/test_browser_camofox_state.py b/tests/tools/test_browser_camofox_state.py index f726dd777c24..d1b8c02e4a3a 100644 --- a/tests/tools/test_browser_camofox_state.py +++ b/tests/tools/test_browser_camofox_state.py @@ -64,4 +64,4 @@ def test_config_version_matches_current_schema(self): # The current schema version is tracked globally; unrelated default # options may bump it after browser defaults are added. - assert DEFAULT_CONFIG["_config_version"] == 20 + assert DEFAULT_CONFIG["_config_version"] == 21 diff --git a/tests/tools/test_terminal_output_transform_hook.py b/tests/tools/test_terminal_output_transform_hook.py index bdbdcc0f5de9..0d922ace06e0 100644 --- a/tests/tools/test_terminal_output_transform_hook.py +++ b/tests/tools/test_terminal_output_transform_hook.py @@ -177,6 +177,10 @@ def test_terminal_output_transform_integration_with_real_plugin(monkeypatch, tmp plugins_dir = hermes_home / "plugins" plugin_dir = plugins_dir / "terminal_transform" plugin_dir.mkdir(parents=True) + (hermes_home / "config.yaml").write_text( + "plugins:\n enabled:\n - terminal_transform\n", + encoding="utf-8", + ) (plugin_dir / "plugin.yaml").write_text("name: terminal_transform\n", encoding="utf-8") (plugin_dir / "__init__.py").write_text( "def register(ctx):\n" diff --git a/tools/send_message_tool.py b/tools/send_message_tool.py index 534426607463..fbf5f046803a 100644 --- a/tools/send_message_tool.py +++ b/tools/send_message_tool.py @@ -202,6 +202,7 @@ def _handle_send(args): "discord": Platform.DISCORD, "slack": Platform.SLACK, "whatsapp": Platform.WHATSAPP, + "line": Platform.LINE, "signal": Platform.SIGNAL, "bluebubbles": Platform.BLUEBUBBLES, "qqbot": Platform.QQBOT, @@ -413,6 +414,7 @@ async def _send_to_platform(platform, pconfig, chat_id, message, thread_id=None, from gateway.platforms.base import BasePlatformAdapter, utf16_len from gateway.platforms.discord import DiscordAdapter from gateway.platforms.slack import SlackAdapter + from gateway.platforms.line import LineAdapter # Telegram adapter import is optional (requires python-telegram-bot) try: @@ -442,6 +444,7 @@ async def _send_to_platform(platform, pconfig, chat_id, message, thread_id=None, Platform.TELEGRAM: TelegramAdapter.MAX_MESSAGE_LENGTH if _telegram_available else 4096, Platform.DISCORD: DiscordAdapter.MAX_MESSAGE_LENGTH, Platform.SLACK: SlackAdapter.MAX_MESSAGE_LENGTH, + Platform.LINE: LineAdapter.MAX_MESSAGE_LENGTH, } if _feishu_available: _MAX_LENGTHS[Platform.FEISHU] = FeishuAdapter.MAX_MESSAGE_LENGTH @@ -534,6 +537,8 @@ async def _send_to_platform(platform, pconfig, chat_id, message, thread_id=None, result = await _send_slack(pconfig.token, chat_id, chunk) elif platform == Platform.WHATSAPP: result = await _send_whatsapp(pconfig.extra, chat_id, chunk) + elif platform == Platform.LINE: + result = await _send_line(pconfig, chat_id, chunk) elif platform == Platform.SIGNAL: result = await _send_signal(pconfig.extra, chat_id, chunk) elif platform == Platform.EMAIL: @@ -971,6 +976,38 @@ async def _send_whatsapp(extra, chat_id, message): return _error(f"WhatsApp send failed: {e}") +async def _send_line(pconfig, chat_id, message): + """Send a LINE push message.""" + try: + import httpx + except ImportError: + return {"error": "httpx not installed"} + + token = pconfig.token or pconfig.extra.get("channel_access_token") or os.getenv("LINE_CHANNEL_ACCESS_TOKEN", "") + api_base_url = (pconfig.extra.get("api_base_url") or os.getenv("LINE_API_BASE_URL", "https://api.line.me")).rstrip("/") + if not token: + return {"error": "LINE not configured (LINE_CHANNEL_ACCESS_TOKEN required)"} + + try: + async with httpx.AsyncClient(timeout=30.0) as client: + response = await client.post( + f"{api_base_url}/v2/bot/message/push", + headers={ + "Authorization": f"Bearer {token}", + "Content-Type": "application/json", + }, + json={"to": chat_id, "messages": [{"type": "text", "text": message}]}, + ) + if response.status_code >= 400: + return _error(f"LINE API error ({response.status_code}): {response.text}") + data = response.json() if response.content else {} + sent_messages = data.get("sentMessages") or [] + message_id = sent_messages[0].get("id") if sent_messages else None + return {"success": True, "platform": "line", "chat_id": chat_id, "message_id": message_id} + except Exception as e: + return _error(f"LINE send failed: {e}") + + async def _send_signal(extra, chat_id, message): """Send via signal-cli JSON-RPC API.""" try: diff --git a/toolsets.py b/toolsets.py index f1dc7fca1c1e..b816109f39a2 100644 --- a/toolsets.py +++ b/toolsets.py @@ -316,6 +316,12 @@ "tools": _HERMES_CORE_TOOLS, "includes": [] }, + + "hermes-line": { + "description": "LINE bot toolset - text messaging via LINE Messaging API", + "tools": _HERMES_CORE_TOOLS, + "includes": [] + }, "hermes-slack": { "description": "Slack bot toolset - full access for workspace use (terminal has safety checks)", @@ -410,7 +416,7 @@ "hermes-gateway": { "description": "Gateway toolset - union of all messaging platform tools", "tools": [], - "includes": ["hermes-telegram", "hermes-discord", "hermes-whatsapp", "hermes-slack", "hermes-signal", "hermes-bluebubbles", "hermes-homeassistant", "hermes-email", "hermes-sms", "hermes-mattermost", "hermes-matrix", "hermes-dingtalk", "hermes-feishu", "hermes-wecom", "hermes-wecom-callback", "hermes-weixin", "hermes-qqbot", "hermes-webhook"] + "includes": ["hermes-telegram", "hermes-discord", "hermes-whatsapp", "hermes-line", "hermes-slack", "hermes-signal", "hermes-bluebubbles", "hermes-homeassistant", "hermes-email", "hermes-sms", "hermes-mattermost", "hermes-matrix", "hermes-dingtalk", "hermes-feishu", "hermes-wecom", "hermes-wecom-callback", "hermes-weixin", "hermes-qqbot", "hermes-webhook"] } }