Files
hermes-agent/plugins/platforms/line/adapter.py
teknium1 4586cde64d chore: mark the deliberate /tmp literals and shrink the lint baseline to one code block
The seventeen remaining literals are container-side paths, AF_UNIX socket-path-limit
candidates on darwin, detection needles, guard regexes and guidance text that tells the
model to avoid /tmp. Each carries an inline `no-tmp: ok — <why>` so the reason lives
next to the line; the baseline keeps only a fenced tree listing where a marker would render.
2026-09-19 10:44:26 -07:00

994 lines
50 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""LINE Messaging API adapter: aiohttp webhook (signature-verified) → BasePlatformAdapter.
* Reply token preferred (free, single-use, ~60s TTL), metered Push as fallback.
* Slow-LLM postback button past ``slow_response_threshold`` (45s; 0 disables): the reply
token is burned on a Template Buttons bubble; the tap yields a fresh free token that
delivers the cached answer (PENDING → READY → DELIVERED, ERROR on cancel). Mid-turn
progress/status sends (``_interim_send`` metadata / busy-ack prefixes) bypass the cache;
a stale or vanished mapping falls through to a live wire send (#106446).
* Three allowlists (users U…, groups C…, rooms R…); ``LINE_ALLOW_ALL_USERS`` is dev-only.
* Media via public HTTPS only: local files served under ``/line/media/<token>/<name>``
(allowed-roots guard); ``LINE_PUBLIC_URL`` overrides host:port behind tunnels/wildcards.
* ≤5 message objects per call; text chunked at 4500 chars (bubble hard limit 5000).
"""
from __future__ import annotations
import asyncio
import base64
import contextlib
import enum
import hashlib
import hmac
import json
import logging
import mimetypes
import os
import re
import secrets
import sys
import tempfile
import time
import uuid
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Callable, Dict, List, Optional, Set, Tuple
from urllib.parse import quote as _urlquote
from gateway.platforms._shared import (
get_scoped_secret as _get_scoped_secret, seed_extra_from_env as _seed_extra_from_env, send_error
)
from gateway.platforms.base import (
gateway_trust_env, BasePlatformAdapter, SendResult,
cache_audio_from_bytes_async, cache_document_from_bytes_async, cache_image_from_bytes_async,
cache_video_from_bytes_async,
)
from gateway.platforms.helpers import MessageDeduplicator, cancel_task
from gateway.platforms.event import MessageEvent, MessageType
from gateway.config import Platform
logger = logging.getLogger(__name__)
LINE_REPLY_URL = "https://api.line.me/v2/bot/message/reply"
LINE_PUSH_URL = "https://api.line.me/v2/bot/message/push"
LINE_LOADING_URL = "https://api.line.me/v2/bot/chat/loading/start"
LINE_CONTENT_URL_FMT = "https://api-data.line.me/v2/bot/message/{message_id}/content"
LINE_BOT_INFO_URL = "https://api.line.me/v2/bot/info"
LINE_PER_BUBBLE_CHARS = 5000 # LINE hard limit
LINE_SAFE_BUBBLE_CHARS = 4500 # conservative chunking limit
LINE_MAX_MESSAGES_PER_CALL = 5
LINE_REPLY_TOKEN_TTL_SECONDS = 50 # below LINE's ~60s
WEBHOOK_BODY_MAX_BYTES = 1_048_576 # 1 MiB — webhooks are tiny JSON
DEFAULT_WEBHOOK_PORT = 8646
DEFAULT_WEBHOOK_PATH = "/line/webhook"
DEFAULT_MEDIA_PATH_PREFIX = "/line/media"
# ``None`` → asyncio binds BOTH address families (mirrors gateway/platforms/webhook.py).
# "0.0.0.0" is unreachable on IPv6-only networks (Fly.io 6PN → 502s); "::" breaks IPv4
# loopback probes on IPV6_V6ONLY=1 hosts. Pin via ``LINE_HOST`` / ``extra.host``.
DEFAULT_HOST = None
_WILDCARD_HOSTS = frozenset({"0.0.0.0", "::", ""}) # LINE can't fetch media from these → public URL required
DEFAULT_SLOW_RESPONSE_THRESHOLD = 45.0 # seconds; 0 disables the postback button
DEFAULT_PENDING_REPLY_TEXT = "🤔 Still thinking. Tap below to fetch the answer when it's ready."
DEFAULT_BUTTON_LABEL = "Get answer"
DEFAULT_DELIVERED_TEXT = "Already replied ✅"
DEFAULT_INTERRUPTED_TEXT = "Run was interrupted before completion."
DEFAULT_EXPIRED_TEXT = "That request has expired — send your message again."
MEDIA_TOKEN_TTL_SECONDS = 1800 # 30 minutes; LINE caches the URL aggressively
LINE_IMAGE_MAX_BYTES = 10 * 1024 * 1024 # 10 MB per LINE docs
LINE_AV_MAX_BYTES = 200 * 1024 * 1024 # 200 MB for voice/video
# LINE message type → normalized MessageType. LINE audio is recorded voice clips →
# VOICE (STT path), like Telegram/WhatsApp. Unknown types fall back to TEXT.
_LINE_MESSAGE_TYPES = {
"text": MessageType.TEXT, "image": MessageType.PHOTO, "video": MessageType.VIDEO, "audio": MessageType.VOICE,
"file": MessageType.DOCUMENT, "location": MessageType.LOCATION, "sticker": MessageType.STICKER}
# 1×1 transparent PNG: fallback video preview (LINE requires ``previewImageUrl``).
_FALLBACK_PNG_PREVIEW = bytes.fromhex(
"89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c4"
"890000000d49444154789c63000100000005000100377a7ff20000000049454e"
"44ae426082")
# Markdown LINE can't render, applied in order (code blocks first so their content survives).
_MD_STRIP_RULES: Tuple[Tuple[re.Pattern, Any], ...] = (
(re.compile(r"```[a-zA-Z0-9_+-]*\n?(.*?)```", re.DOTALL), lambda m: m.group(1).rstrip("\n")),
(re.compile(r"`([^`]+)`"), r"\1"),
(re.compile(r"\[([^\]]+)\]\((https?://[^\s)]+)\)"), lambda m: f"{m.group(1)} ({m.group(2)})"),
(re.compile(r"\*\*(.+?)\*\*"), r"\1"),
(re.compile(r"(?<!\*)\*(?!\s)(.+?)(?<!\s)\*(?!\*)"), r"\1"),
(re.compile(r"^#{1,6}\s+", re.MULTILINE), ""),
(re.compile(r"^[\s]*[-*+]\s+", re.MULTILINE), "• "))
def strip_markdown_preserving_urls(text: str) -> str:
"""Strip Markdown LINE can't render; ``[label](url)`` → ``label (url)`` keeps URLs
tappable (LINE auto-links bare URLs only). Code-block content is kept.
Source: PR #18153 (leepoweii) — adapted to keep code-block content visible (LINE users frequently want
command snippets to land as plain text, not be eaten by the fence).
"""
if not text:
return text
for pattern, repl in _MD_STRIP_RULES:
text = pattern.sub(repl, text)
return text
def split_for_line(text: str, max_chars: int = LINE_SAFE_BUBBLE_CHARS) -> List[str]:
"""Split into ≤5 LINE bubbles at paragraph/line/word breaks; overflow is ellipsised."""
if not text or len(text) <= max_chars:
return [text] if text else []
chunks: List[str] = []
remaining = text
while remaining and len(chunks) < LINE_MAX_MESSAGES_PER_CALL and len(remaining) > max_chars:
# Prefer paragraph, then line, then word breaks past the half-way mark; else a hard cut.
cuts = [remaining.rfind(sep, 0, max_chars) for sep in ("\n\n", "\n", " ")]
cut = next((c for c in cuts if c >= int(max_chars * 0.5)), cuts[-1])
if cut <= 0:
cut = max_chars
chunks.append(remaining[:cut].rstrip())
remaining = remaining[cut:].lstrip()
if remaining and len(chunks) < LINE_MAX_MESSAGES_PER_CALL:
chunks.append(remaining)
elif remaining: # budget exhausted → ellipsis on the last bubble
chunks[-1] = chunks[-1][: max_chars - 1].rstrip() + "…"
return chunks
def verify_line_signature(body: bytes, signature: str, channel_secret: str) -> bool:
"""Verify ``X-Line-Signature``: base64(HMAC-SHA256(secret, raw body)), constant-time."""
if not signature or not channel_secret or body is None:
return False
try:
digest = hmac.new(channel_secret.encode("utf-8"), body, hashlib.sha256).digest()
expected = base64.b64encode(digest).decode("utf-8")
except Exception:
return False
# Bytes: compare_digest raises TypeError on non-ASCII str, and the header is raw.
return hmac.compare_digest(expected.encode(), signature.encode())
class State(enum.Enum):
"""Slow-LLM postback cache states."""
PENDING = "pending" # button sent, LLM still running
READY = "ready" # response cached, waiting for postback tap
DELIVERED = "delivered"
ERROR = "error" # LLM raised / interrupted; error text cached
@dataclass
class _CacheEntry:
state: State
payload: Any = None
class RequestCache:
"""In-memory cache for slow-LLM postback retrieval (PENDING → READY|ERROR → DELIVERED).
We keep the same model here. See #18153.
"""
def __init__(self) -> None:
self._entries: Dict[str, _CacheEntry] = {}
def register_pending(self, chat_id: str) -> str:
rid = str(uuid.uuid4())
self._entries[rid] = _CacheEntry(state=State.PENDING)
return rid
def get(self, request_id: str) -> Optional[_CacheEntry]:
return self._entries.get(request_id)
def _transition(self, request_id: str, allowed: Set[State], state: State, payload: Any = None) -> None:
entry = self._entries.get(request_id)
if entry is not None and entry.state in allowed:
entry.state = state
entry.payload = entry.payload if state is State.DELIVERED else payload
def set_ready(self, request_id: str, payload: Any) -> None:
self._transition(request_id, {State.PENDING}, State.READY, payload)
def set_error(self, request_id: str, message: str) -> None:
self._transition(request_id, {State.PENDING}, State.ERROR, message)
def mark_delivered(self, request_id: str) -> None:
self._transition(request_id, {State.READY, State.ERROR}, State.DELIVERED)
# LINE source type → (id key, normalized chat_type)
_SOURCE_KINDS = {"group": ("groupId", "group"), "room": ("roomId", "room"), "user": ("userId", "dm")}
def _resolve_chat(source: Dict[str, Any]) -> Tuple[str, str]:
"""Return ``(chat_id, chat_type)`` from a LINE event ``source`` block (user/group/room).
Source: PR #21023 (perng), unchanged.
"""
kind = _SOURCE_KINDS.get((source or {}).get("type", ""))
return ("", "dm") if kind is None else (source.get(kind[0], ""), kind[1])
def _allowed_for_source(
source: Dict[str, Any], *, allow_all: bool, user_ids: Set[str], group_ids: Set[str], room_ids: Set[str]) -> bool:
"""Three-list gate: users, groups, rooms.
See #18153.
"""
if allow_all:
return True
sid, chat_type = _resolve_chat(source)
return bool(sid) and sid in {"dm": user_ids, "group": group_ids, "room": room_ids}[chat_type]
class _LineClient:
"""Thin aiohttp wrapper around the LINE Messaging API (no ``line-bot-sdk`` dependency)."""
def __init__(self, channel_access_token: str, *, timeout: float = 15.0) -> None:
self._token = channel_access_token
self._timeout = timeout
self._headers = {"Authorization": f"Bearer {channel_access_token}", "Content-Type": "application/json"}
@staticmethod
def _session(timeout: float):
import aiohttp
return aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=timeout), trust_env=gateway_trust_env())
async def _post_messages(self, url: str, label: str, payload: Dict[str, Any]) -> None:
async with self._session(self._timeout) as session:
async with session.post(url, headers=self._headers, json=payload) as resp:
if resp.status >= 400:
body = await resp.text()
raise RuntimeError(f"LINE {label} {resp.status}: {body[:200]}")
async def reply(self, reply_token: str, messages: List[Dict[str, Any]]) -> None:
await self._post_messages(LINE_REPLY_URL, "reply", {"replyToken": reply_token, "messages": messages})
async def push(self, chat_id: str, messages: List[Dict[str, Any]]) -> None:
await self._post_messages(LINE_PUSH_URL, "push", {"to": chat_id, "messages": messages})
async def loading(self, chat_id: str, seconds: int = 60) -> None:
"""Loading indicator (DM only). LINE rejects this for groups/rooms."""
if not chat_id or not chat_id.startswith("U"):
return
import aiohttp # noqa: F401 — ImportError must escape the swallow-all below
clamped = max(5, min(60, (seconds // 5) * 5 or 5)) # LINE: 5-step increments, max 60
try:
async with self._session(5.0) as session:
await session.post(LINE_LOADING_URL, headers=self._headers, json={"chatId": chat_id, "loadingSeconds": clamped})
except Exception as exc: # best-effort; never raise
logger.debug("LINE loading indicator failed: %s", exc)
async def fetch_content(self, message_id: str) -> bytes:
async with self._session(30.0) as session:
url = LINE_CONTENT_URL_FMT.format(message_id=message_id)
async with session.get(url, headers={"Authorization": f"Bearer {self._token}"}) as resp:
if resp.status >= 400:
raise RuntimeError(f"LINE content {resp.status}")
return await resp.read()
async def get_bot_user_id(self) -> Optional[str]:
"""Fetch this channel's own userId so we can filter self-messages."""
import aiohttp # noqa: F401 — ImportError must escape the swallow-all below
try:
async with self._session(10.0) as session:
async with session.get(LINE_BOT_INFO_URL, headers=self._headers) as resp:
return None if resp.status >= 400 else (await resp.json()).get("userId")
except Exception:
return None
def _text_message(text: str) -> Dict[str, Any]:
"""Build a LINE text message object, capped to per-bubble max."""
return {"type": "text", "text": text if len(text) <= LINE_PER_BUBBLE_CHARS else text[: LINE_PER_BUBBLE_CHARS - 1] + "…"}
def _text_messages(content: str) -> List[Dict[str, Any]]:
"""Markdown-strip, chunk and cap ``content`` into ≤5 LINE text messages."""
chunks = split_for_line(strip_markdown_preserving_urls(content))
return [_text_message(c) for c in chunks][:LINE_MAX_MESSAGES_PER_CALL]
def build_postback_button_message(text: str, button_label: str, request_id: str) -> Dict[str, Any]:
"""Slow-LLM postback bubble. Template Buttons stay tappable from history (Quick
Reply chips vanish on the next message). LINE limits: text ≤160, altText ≤400.
See #18153.
"""
truncated = text if len(text) <= 160 else text[:157] + "..."
alt = text if len(text) <= 400 else text[:397] + "..."
action = {
"type": "postback",
"label": button_label[:20] or "Get answer",
"data": json.dumps({"action": "show_response", "request_id": request_id}),
"displayText": button_label[:300] or "Get answer"}
return {"type": "template", "altText": alt, "template": {"type": "buttons", "text": truncated, "actions": [action]}}
# Gateway busy-ack prefixes (interrupting / queued / steered / background review / working
# heartbeat); these bypass a PENDING postback cache so they land as visible bubbles.
_SYSTEM_BYPASS_PREFIXES: Tuple[str, ...] = ("⚡ Interrupting", "⏳ Queued", "⏩ Steered", "💾", "⏳ Working")
def _is_system_bypass(content: str) -> bool:
return bool(content) and any(content.startswith(p) for p in _SYSTEM_BYPASS_PREFIXES)
def _is_interim_send(content: str, metadata: Optional[Dict[str, Any]] = None) -> bool:
"""True for mid-turn progress/status sends that must not become the cached answer.
Purpose-first: the gateway stamps every mid-turn send with ``_interim_send`` metadata
(``gateway.run._interim_metadata``), which survives to the adapter and is stripped
before the wire. The text prefixes remain a fallback for callers that pass no
metadata. See #106446.
"""
if (metadata or {}).get("_interim_send"):
return True
return _is_system_bypass(content)
def _csv_set(value: str) -> Set[str]:
return {x.strip() for x in (value or "").split(",") if x.strip()}
def _truthy_env(name: str, default: bool = False) -> bool:
# Scoped read: under multiplex os.environ is the DEFAULT profile's allow-all flag.
v = _get_scoped_secret(name)
return default if v is None else v.strip().lower() in {"1", "true", "yes", "on"}
def _credentials(config) -> Tuple[str, str]:
"""Return ``(channel_access_token, channel_secret)`` from scoped secrets, then ``extra``."""
extra = getattr(config, "extra", {}) or {}
return (
_get_scoped_secret("LINE_CHANNEL_ACCESS_TOKEN") or extra.get("channel_access_token", ""),
_get_scoped_secret("LINE_CHANNEL_SECRET") or extra.get("channel_secret", ""))
def _coerce(cast: Callable[[Any], Any], value: Any, default: Any) -> Any:
try:
return cast(value)
except (TypeError, ValueError):
return default
# Outbound media kinds → (size cap, size error, missing-public-URL error).
_OUTBOUND_MEDIA = {
"image": (
LINE_IMAGE_MAX_BYTES, "image exceeds 10 MB LINE limit",
"LINE_PUBLIC_URL must be set to send images (LINE only accepts publicly reachable HTTPS URLs)"),
"audio": (LINE_AV_MAX_BYTES, "audio exceeds 200 MB LINE limit", "LINE_PUBLIC_URL must be set to send audio"),
"video": (LINE_AV_MAX_BYTES, "video exceeds 200 MB LINE limit", "LINE_PUBLIC_URL must be set to send video"),
}
# Inbound media kinds → cached file extension.
_INBOUND_MEDIA_EXT = {"image": ".jpg", "audio": ".m4a", "video": ".mp4", "file": ".bin"}
_INBOUND_AV_CACHERS = {"audio": cache_audio_from_bytes_async, "video": cache_video_from_bytes_async}
_LIFECYCLE_EVENTS = frozenset({"follow", "unfollow", "join", "leave"})
_ENV_SEED_KEYS = (("LINE_PORT", "port", int), ("LINE_HOST", "host", None), ("LINE_PUBLIC_URL", "public_url", None))
class LineAdapter(BasePlatformAdapter):
"""LINE Messaging API gateway adapter (no message editing → REQUIRES_EDIT_FINALIZE stays False)."""
# Answers /p/<profile>/... on the default listener for a served secondary (shared_ingress).
serves_profile_prefix: bool = True
def __init__(self, config, **kwargs):
super().__init__(config=config, platform=Platform("line"))
extra = getattr(config, "extra", {}) or {}
def env_or(env: str, key: str, default: Any = "") -> Any:
return _get_scoped_secret(env) or extra.get(key, default)
def allowlist(env: str, key: str) -> Set[str]:
# Scoped read: under multiplex os.environ is the DEFAULT profile's allowlist.
return _csv_set(_get_scoped_secret(env, "")) | set(extra.get(key, []))
self.channel_access_token, self.channel_secret = _credentials(config)
# Host ``None`` → dual-stack bind (see DEFAULT_HOST); empty string collapses to None.
self.webhook_host = env_or("LINE_HOST", "host", DEFAULT_HOST) or DEFAULT_HOST
self.webhook_port = _coerce(int, env_or("LINE_PORT", "port", DEFAULT_WEBHOOK_PORT), DEFAULT_WEBHOOK_PORT)
self.webhook_path = extra.get("webhook_path", DEFAULT_WEBHOOK_PATH)
# Required for media when the bind isn't publicly reachable.
self.public_base_url = (env_or("LINE_PUBLIC_URL", "public_url") or "").rstrip("/")
self.allow_all = _truthy_env("LINE_ALLOW_ALL_USERS", bool(extra.get("allow_all_users", False)))
self.allowed_users = allowlist("LINE_ALLOWED_USERS", "allowed_users")
self.allowed_groups = allowlist("LINE_ALLOWED_GROUPS", "allowed_groups")
self.allowed_rooms = allowlist("LINE_ALLOWED_ROOMS", "allowed_rooms")
# Slow-LLM postback button threshold + user-overridable copy
threshold = env_or("LINE_SLOW_RESPONSE_THRESHOLD", "slow_response_threshold", DEFAULT_SLOW_RESPONSE_THRESHOLD)
self.slow_response_threshold = _coerce(float, threshold, DEFAULT_SLOW_RESPONSE_THRESHOLD)
for attr, env, default in (
("pending_text", "LINE_PENDING_TEXT", DEFAULT_PENDING_REPLY_TEXT),
("button_label", "LINE_BUTTON_LABEL", DEFAULT_BUTTON_LABEL),
("delivered_text", "LINE_DELIVERED_TEXT", DEFAULT_DELIVERED_TEXT),
("interrupted_text", "LINE_INTERRUPTED_TEXT", DEFAULT_INTERRUPTED_TEXT),
("expired_text", "LINE_EXPIRED_TEXT", DEFAULT_EXPIRED_TEXT)):
setattr(self, attr, env_or(env, attr, default))
# Runtime state
self._client: Optional[_LineClient] = None
self._app = self._runner = self._site = None # aiohttp web.Application / AppRunner / TCPSite
self._reply_tokens: Dict[str, Tuple[str, float]] = {} # chat_id → (token, expiry)
self._cache = RequestCache()
# LINE redelivers webhooks for up to a day on non-2xx; no TTL, just a size bound.
self._dedup = MessageDeduplicator(max_size=1000, ttl_seconds=float("inf"))
self._bot_user_id: Optional[str] = None
self._media_tokens: Dict[str, Tuple[str, float]] = {} # token → (path, expiry)
self._media_temp_paths: Set[str] = set()
self._media_ttl = MEDIA_TOKEN_TTL_SECONDS
self._pending_buttons: Dict[str, str] = {} # one outstanding button per chat: chat_id → request_id
def _fail(self, code: str, detail: str, *, retryable: bool = False) -> bool: # fatal connect error → False
self._set_fatal_error(code, detail, retryable=retryable)
return False
async def connect(self, *, is_reconnect: bool = False) -> bool:
if not self.channel_access_token or not self.channel_secret:
return self._fail("config_missing", "LINE_CHANNEL_ACCESS_TOKEN and LINE_CHANNEL_SECRET must be set")
# One profile per channel token; lock on a hash so the secret never hits disk.
tok_hash = hashlib.sha256(self.channel_access_token.encode()).hexdigest()[:16]
if not self._acquire_platform_lock("line", tok_hash, "LINE channel"):
return False
self._client = _LineClient(self.channel_access_token)
try: # best-effort self-userId for self-echo filtering (LINE rarely echoes anyway)
self._bot_user_id = await self._client.get_bot_user_id()
except Exception as exc:
logger.debug("LINE: get_bot_user_id failed: %s", exc)
self._bot_user_id = None
try:
from aiohttp import web
except ImportError:
return self._fail("missing_dep", "aiohttp is required for the LINE adapter — install with `pip install aiohttp`")
self._app = web.Application(client_max_size=WEBHOOK_BODY_MAX_BYTES)
self._app.router.add_post(self.webhook_path, self._handle_webhook)
self._app.router.add_get(f"{self.webhook_path}/health", self._handle_health) # tunnel/proxy probe
self._app.router.add_get(f"{DEFAULT_MEDIA_PATH_PREFIX}/{{token}}/{{filename}}", self._handle_media)
# Plugin-registered routes must be wired before AppRunner.setup() freezes the router.
self._wire_plugin_handlers(self._app)
from gateway.platforms.shared_ingress import bind_listener
try:
# SO_REUSEADDR: on macOS/BSD two sockets with it can silently split traffic →
# disable; on Linux it only allows rebinding past TIME_WAIT → keep default.
# Shared-listener mode (multiplex secondary): no bind; served at /p/<profile>/line/webhook.
self._runner = await bind_listener(
self, self._app, self.webhook_host, self.webhook_port, self.webhook_path,
reuse_address=False if sys.platform == "darwin" else None)
except OSError as exc:
return self._fail(
"bind_failed",
f"Could not bind LINE webhook on {self.webhook_host or 'all IPv4+IPv6 interfaces'}:"
f"{self.webhook_port}: {exc}",
retryable=True)
self._mark_connected()
if self._runner is not None:
logger.info(
"LINE: webhook listening on %s:%s%s%s",
self.webhook_host or "* (all interfaces, IPv4+IPv6)",
self.webhook_port,
self.webhook_path,
f" (public: {self.public_base_url})" if self.public_base_url else "")
return True
async def disconnect(self) -> None:
self._mark_disconnected()
for attr, method in (("_site", "stop"), ("_runner", "cleanup")):
obj = getattr(self, attr)
if obj is not None:
with contextlib.suppress(Exception):
await getattr(obj, method)()
setattr(self, attr, None)
self._app = None
for path in list(self._media_temp_paths):
_unlink_quietly(path)
self._media_temp_paths.clear()
self._media_tokens.clear()
with contextlib.suppress(Exception):
self._release_platform_lock()
async def _handle_health(self, request) -> Any:
from aiohttp import web
return web.json_response({"status": "ok", "platform": "line"})
async def _handle_webhook(self, request) -> Any:
from aiohttp import web
try: # explicit body cap: aiohttp's client_max_size only covers some body modes
body = await request.read()
except Exception as exc:
logger.debug("LINE: read failed: %s", exc)
return web.Response(status=400, text="bad request")
if len(body) > WEBHOOK_BODY_MAX_BYTES:
return web.Response(status=413, text="payload too large")
if not verify_line_signature(body, request.headers.get("X-Line-Signature", ""), self.channel_secret):
return web.Response(status=401, text="invalid signature")
try:
payload = json.loads(body.decode("utf-8"))
except (UnicodeDecodeError, json.JSONDecodeError):
return web.Response(status=400, text="bad json")
for event in payload.get("events", []) or []:
try:
await self._dispatch_event(event)
except Exception:
logger.exception("LINE: dispatch_event failed")
return web.Response(status=200, text="ok")
async def _dispatch_event(self, event: Dict[str, Any]) -> None:
event_type = event.get("type")
source = event.get("source") or {}
webhook_event_id = event.get("webhookEventId", "") or ""
if webhook_event_id and self._dedup.is_duplicate(webhook_event_id): # at-least-once redelivery
logger.debug("LINE: ignoring duplicate webhook event %s", webhook_event_id)
return
if self._bot_user_id and source.get("userId", "") == self._bot_user_id:
return
if not _allowed_for_source(source, allow_all=self.allow_all, user_ids=self.allowed_users,
group_ids=self.allowed_groups, room_ids=self.allowed_rooms):
logger.info("LINE: rejecting unauthorized source %s", source)
return
if event_type == "message":
await self._handle_message_event(event)
elif event_type == "postback":
await self._handle_postback_event(event)
elif event_type in _LIFECYCLE_EVENTS:
logger.info("LINE: lifecycle event %s from %s", event_type, source)
else:
logger.debug("LINE: ignoring event type %r", event_type)
async def _handle_message_event(self, event: Dict[str, Any]) -> None:
msg = event.get("message") or {}
msg_type, message_id = msg.get("type", ""), msg.get("id", "")
reply_token = event.get("replyToken", "")
source = event.get("source") or {}
chat_id, chat_type = _resolve_chat(source)
user_id = source.get("userId", "") or chat_id
if chat_id and reply_token: # stash the reply token for outbound use
self._reply_tokens[chat_id] = (reply_token, time.time() + LINE_REPLY_TOKEN_TTL_SECONDS)
media_urls: List[str] = []
media_types: List[str] = []
if msg_type == "text":
text = msg.get("text", "") or ""
elif msg_type in _INBOUND_MEDIA_EXT: # fetch, cache, surface a vision-friendly local path
local_path, media_type = await self._download_media(
message_id, msg_type, filename=msg.get("fileName") or msg.get("file_name"))
if local_path:
media_urls, media_types = [local_path], [media_type]
text = f"[{msg_type}]"
elif msg_type == "sticker":
text = f"[sticker: {', '.join(msg['keywords'])}]" if msg.get("keywords") else "[sticker]"
elif msg_type == "location":
text = f"[location: {msg.get('title', '')} {msg.get('address', '')}]".strip()
else:
text = f"[unsupported message type: {msg_type}]"
if chat_type == "dm" and self._client: # best-effort typing indicator (DM only)
asyncio.create_task(self._client.loading(chat_id))
source_obj = self.build_source(
chat_id=chat_id, chat_type=chat_type, user_id=user_id, user_name=user_id, chat_name=chat_id,
message_id=message_id)
await self.handle_message(MessageEvent(
text=text, message_type=_LINE_MESSAGE_TYPES.get(msg_type, MessageType.TEXT), source=source_obj,
raw_message=event, message_id=message_id, media_urls=media_urls, media_types=media_types))
async def _handle_postback_event(self, event: Dict[str, Any]) -> None:
"""User tapped the slow-LLM postback button — deliver the cached payload. READY replies (push
fallback) and ERROR replies settle the entry; DELIVERED / PENDING just re-issue their notice."""
reply_token = event.get("replyToken", "")
chat_id, _ = _resolve_chat(event.get("source") or {})
try:
parsed = json.loads((event.get("postback") or {}).get("data", "") or "")
except (TypeError, json.JSONDecodeError):
return
request_id = parsed.get("request_id", "") if parsed.get("action") == "show_response" else ""
entry = self._cache.get(request_id) if request_id else None
if not self._client or not reply_token:
return
if entry is None:
# The tap targets a button whose entry is gone (expired / lost with
# process state). Tell the user instead of leaving the chat stuck,
# and drop any mapping that still points at the dead request so
# later answers reach the wire. See #106446.
if request_id and self._pending_buttons.get(chat_id) == request_id:
self._pending_buttons.pop(chat_id, None)
messages = [_text_message(self.expired_text)]
try:
await self._client.reply(reply_token, messages)
except Exception as exc:
logger.debug("LINE: postback expired-notice reply failed: %s", exc)
return
state = entry.state
if state is State.READY:
messages = _text_messages(str(entry.payload or ""))
elif state is State.ERROR:
messages = [_text_message(str(entry.payload or self.interrupted_text))]
else:
messages = [_text_message(self.delivered_text if state is State.DELIVERED else self.pending_text)]
try:
await self._client.reply(reply_token, messages)
except Exception as exc:
if state is not State.READY:
if state is State.ERROR:
logger.warning("LINE: postback ERROR reply failed: %s", exc)
return
logger.warning("LINE: postback reply failed (%s); falling back to push", exc)
try:
await self._client.push(chat_id, messages)
except Exception as exc2:
logger.error("LINE: postback push fallback failed: %s", exc2)
return
if state in (State.READY, State.ERROR):
self._cache.mark_delivered(request_id)
self._pending_buttons.pop(chat_id, None)
async def _download_media(
self, message_id: str, msg_type: str, *, filename: Optional[str] = None) -> Tuple[Optional[str], str]:
if not self._client or not message_id:
return None, ""
try:
data = await self._client.fetch_content(message_id)
except Exception as exc:
logger.warning("LINE: failed to fetch %s content for %s: %s", msg_type, message_id, exc)
return None, ""
ext = _INBOUND_MEDIA_EXT.get(msg_type, ".bin")
try:
if msg_type == "image":
return await cache_image_from_bytes_async(data, ext=ext), "image/jpeg"
if msg_type in _INBOUND_AV_CACHERS:
return await _INBOUND_AV_CACHERS[msg_type](data, ext=ext), mimetypes.guess_type(f"{msg_type}{ext}")[0] or f"{msg_type}/mp4"
document_name = filename or f"line_file{ext}"
mime = mimetypes.guess_type(document_name)[0] or "application/octet-stream"
return await cache_document_from_bytes_async(data, document_name), mime
except Exception as exc:
logger.warning("LINE: failed to cache %s payload: %s", msg_type, exc)
return None, ""
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="LINE adapter not connected")
# A PENDING postback button caches the response for the tap — except interim /
# system sends (progress heartbeats, busy-acks), which must land as visible
# bubbles. Interim detection is purpose-first: the gateway marks every mid-turn
# status send with ``_interim_send`` (see ``gateway.run._interim_metadata``);
# the text prefixes stay as belt-and-suspenders for metadata-less callers.
pending_rid = self._pending_buttons.get(chat_id)
if pending_rid and not _is_interim_send(content, metadata):
entry = self._cache.get(pending_rid)
if entry is not None and entry.state is State.PENDING:
self._cache.set_ready(pending_rid, content)
return SendResult(success=True, message_id=pending_rid)
# Stale mapping: the entry is READY/DELIVERED/ERROR or gone entirely —
# absorbing this send would silently swallow the answer behind a dead
# button. Clear the mapping and deliver on the wire. See #106446.
self._pending_buttons.pop(chat_id, None)
# System busy-acks (interrupting / queued / steered) and progress heartbeats
# bypass the postback cache and route directly to LINE so they reach the user
# as visible bubbles. Source: PR #18153, #106446.
return await self._send_text_chunks(chat_id, content, force_push=False)
async def _send_text_chunks(self, chat_id: str, content: str, *, force_push: bool) -> SendResult:
return await self._send_messages(chat_id, _text_messages(content), force_push=force_push, text=True)
def _consume_reply_token(self, chat_id: str) -> Tuple[str, bool]:
"""Consume a stashed reply token if present and unexpired → ``(token, used_reply)``."""
token, expires_at = self._reply_tokens.pop(chat_id, None) or ("", 0.0)
return (token, True) if token and time.time() < expires_at else ("", False)
async def send_typing(self, chat_id: str, metadata=None) -> None:
"""Trigger LINE's loading-animation indicator (DM only)."""
if self._client and chat_id:
await self._client.loading(chat_id)
async def get_chat_info(self, chat_id: str) -> Dict[str, Any]:
"""Best-effort chat info inferred from the ID prefix (U=user, C=group, R=room)."""
chat_type = {"U": "dm", "C": "group", "R": "channel"}.get((chat_id or "")[:1], "dm")
return {"name": chat_id or "", "type": chat_type}
def format_message(self, content: str) -> str:
"""Strip Markdown that LINE can't render. URLs are preserved."""
return strip_markdown_preserving_urls(content)
async def _keep_typing(self, chat_id: str, *args, **kwargs) -> None:
"""Wrap the base typing heartbeat; fire the slow-LLM postback button at threshold."""
if self.slow_response_threshold <= 0 or not self._client or not chat_id:
await super()._keep_typing(chat_id, *args, **kwargs)
return
async def _fire_postback() -> None:
await asyncio.sleep(self.slow_response_threshold)
# Only fire while a usable reply token remains (the agent responding
# consumes it) and no button is already outstanding.
if chat_id not in self._reply_tokens or chat_id in self._pending_buttons:
return
rid = self._cache.register_pending(chat_id)
self._pending_buttons[chat_id] = rid
token, used = self._consume_reply_token(chat_id)
if not used:
self._pending_buttons.pop(chat_id, None)
return
msg = build_postback_button_message(self.pending_text, self.button_label, rid)
try:
await self._client.reply(token, [msg])
logger.info("LINE: sent slow-LLM postback button for chat %s (rid=%s)", chat_id, rid)
except Exception as exc:
logger.warning("LINE: postback button send failed: %s", exc)
self._pending_buttons.pop(chat_id, None)
post_task = asyncio.create_task(_fire_postback())
try:
await super()._keep_typing(chat_id, *args, **kwargs)
finally:
await cancel_task(post_task)
async def interrupt_session_activity(self, session_key: str, chat_id: str) -> None:
"""Resolve any orphan PENDING postback so the button doesn't loop."""
await super().interrupt_session_activity(session_key, chat_id)
rid = self._pending_buttons.pop(chat_id, None)
if rid:
self._cache.set_error(rid, self.interrupted_text)
def _register_media(self, file_path: str, *, cleanup: bool = False) -> str:
"""Register a local file for HTTPS serving (evicting expired tokens); return the URL token."""
now = time.time()
for token, (path, exp) in list(self._media_tokens.items()):
if now > exp:
self._media_tokens.pop(token, None)
if path in self._media_temp_paths:
self._media_temp_paths.discard(path)
_unlink_quietly(path)
resolved = str(Path(file_path).resolve())
token = secrets.token_urlsafe(32)
self._media_tokens[token] = (resolved, now + self._media_ttl)
if cleanup:
self._media_temp_paths.add(resolved)
return token
def _media_url(self, token: str, filename: str) -> str:
if self.public_base_url:
base = self.public_base_url
elif getattr(self, "_shared_ingress_base", None):
base = self._shared_ingress_base # default listener's /p/<profile> prefix (multiplex secondary)
else:
# Wildcard/dual-stack binds have no fetchable hostname (the _missing_public_url
# guard should have fired); fall back to localhost so the URL is well-formed.
host = "127.0.0.1" if self._missing_public_url() else self.webhook_host
base = f"https://{host}" if self.webhook_port == 443 else f"https://{host}:{self.webhook_port}"
return f"{base}{DEFAULT_MEDIA_PATH_PREFIX}/{token}/{_urlquote(filename, safe='')}"
def _serve_file(self, path: Path) -> str:
return self._media_url(self._register_media(str(path.resolve())), path.name)
def _missing_public_url(self) -> bool:
"""True when no LINE_PUBLIC_URL is set and the bind host is wildcard/dual-stack ``None``."""
if self.public_base_url or getattr(self, "_shared_ingress_base", None):
return False
return self.webhook_host is None or self.webhook_host in _WILDCARD_HOSTS
def _check_media_file(self, kind: str, file_path: str) -> Tuple[Optional[Path], Optional[SendResult]]:
"""Shared preflight for send_image_file/send_voice/send_video → ``(path, error)``."""
max_bytes, size_error, url_error = _OUTBOUND_MEDIA[kind]
path = Path(file_path)
if not path.is_file():
return None, SendResult(success=False, error=f"{kind} file not found: {file_path}")
for failed, error in (
(path.stat().st_size > max_bytes, size_error),
(not self._client, "LINE adapter not connected"),
(self._missing_public_url(), url_error)):
if failed:
return None, SendResult(success=False, error=error)
return path, None
async def _handle_media(self, request) -> Any:
"""Serve a registered local file for LINE's media URLs. Defence-in-depth: the resolved
path is rechecked against allowed roots (tempdir, ``/tmp``→``/private/tmp`` on macOS, HERMES_HOME).
Defence-in-depth: even though ``_register_media`` is only called from trusted internal code, we
recheck the resolved path against an allowed-roots set before serving. Sources allowed:
``tempfile.gettempdir()``, ``/tmp`` (which resolves to ``/private/tmp`` on macOS), and
``HERMES_HOME``. PR #8398.
"""
from aiohttp import web
token = request.match_info["token"]
file_path, expires_at = self._media_tokens.get(token) or ("", 0.0)
if not file_path:
return web.Response(status=404, text="not found")
if time.time() > expires_at:
self._media_tokens.pop(token, None)
return web.Response(status=410, text="gone")
path = Path(file_path)
if not path.is_file():
return web.Response(status=404, text="not found")
try:
from hermes_constants import get_hermes_home
hermes_home = Path(get_hermes_home()).resolve()
except Exception:
hermes_home = Path.home().joinpath(".hermes").resolve()
resolved = path.resolve()
if not any(resolved.is_relative_to(r) for r in (Path(tempfile.gettempdir()).resolve(), Path("/tmp").resolve(), hermes_home)): # no-tmp: ok — macOS /private/tmp alias in the allowed-roots check, not a write target
logger.warning("LINE: refusing to serve outside allowed roots: %s", resolved)
return web.Response(status=403, text="forbidden")
content_type = mimetypes.guess_type(str(path))[0] or "application/octet-stream"
return web.FileResponse(path, headers={"Content-Type": content_type})
async def send_image_file(
self, chat_id: str, image_path: str, caption: Optional[str] = None, metadata: Optional[Dict[str, Any]] = None
) -> SendResult:
path, err = self._check_media_file("image", image_path)
if err:
return err
url = self._serve_file(path)
if not url.lower().startswith("https://"):
return SendResult(success=False, error=f"LINE image URL must be HTTPS: {url}")
msgs: List[Dict[str, Any]] = [{"type": "image", "originalContentUrl": url, "previewImageUrl": url}]
return await self._send_messages(chat_id, msgs + ([_text_message(caption)] if caption else []))
async def send_voice(
self, chat_id: str, audio_path: str, duration_ms: int = 1000, metadata: Optional[Dict[str, Any]] = None
) -> SendResult:
path, err = self._check_media_file("audio", audio_path)
if err:
return err
msg = {"type": "audio", "originalContentUrl": self._serve_file(path), "duration": int(duration_ms)}
return await self._send_messages(chat_id, [msg])
async def send_video(
self, chat_id: str, video_path: str, preview_path: Optional[str] = None,
metadata: Optional[Dict[str, Any]] = None) -> SendResult:
path, err = self._check_media_file("video", video_path)
if err:
return err
# LINE requires previewImageUrl: use the supplied preview, else a stdlib 1×1 PNG.
# Use one if supplied, otherwise write a stdlib 1×1 PNG to /tmp and serve it. PR #8398.
if preview_path and Path(preview_path).is_file():
preview_url = self._serve_file(Path(preview_path))
else:
tmp = tempfile.NamedTemporaryFile(suffix=".png", delete=False)
try:
tmp.write(_FALLBACK_PNG_PREVIEW)
tmp.flush()
tmp.close()
preview_url = self._media_url(self._register_media(tmp.name, cleanup=True), "preview.png")
except Exception:
_unlink_quietly(tmp.name)
raise
msg = {"type": "video", "originalContentUrl": self._serve_file(path), "previewImageUrl": preview_url}
return await self._send_messages(chat_id, [msg])
async def _send_messages(
self, chat_id: str, messages: List[Dict[str, Any]], *, force_push: bool = False, text: bool = False
) -> SendResult:
"""Send built message objects, batched at 5/call: reply token first, then push. ``text``
selects the text contract: reply success reports the token as message_id; push failure logs at error."""
if not self._client:
return SendResult(success=False, error="LINE adapter not connected")
if not messages:
return SendResult(success=True, message_id=None)
n = LINE_MAX_MESSAGES_PER_CALL
batches = [messages[i:i + n] for i in range(0, len(messages), n)]
token, used_reply = self._consume_reply_token(chat_id)
start = 0
if used_reply and not force_push:
try:
await self._client.reply(token, batches[0])
if text:
return SendResult(success=True, message_id=token)
start = 1
except Exception as exc:
logger.info("LINE: reply token rejected (%s); falling back to push", exc)
for i in range(start, len(batches)): # push the rest (reply token is single-use)
try:
await self._client.push(chat_id, batches[i])
except Exception as exc:
if i > 0:
logger.warning("LINE: push for follow-up batch failed: %s", exc)
elif text:
logger.error("LINE: push send failed: %s", exc)
return SendResult(success=False, error=str(exc))
return SendResult(success=True, message_id=None)
def _unlink_quietly(path: str) -> None:
with contextlib.suppress(OSError):
os.unlink(path)
def _env_credentials_present() -> bool:
return bool(_get_scoped_secret("LINE_CHANNEL_ACCESS_TOKEN") and _get_scoped_secret("LINE_CHANNEL_SECRET"))
def check_requirements() -> bool:
"""Plugin gate: require credentials AND aiohttp at runtime."""
if not _env_credentials_present():
return False
try:
import aiohttp # noqa: F401
return True
except ImportError:
return False
def validate_config(config) -> bool:
return all(_credentials(config))
def is_connected(config) -> bool:
"""Surface in ``hermes status`` even before the adapter is instantiated."""
return validate_config(config)
def _env_enablement() -> Optional[Dict[str, Any]]:
"""``env_enablement_fn``: seed ``PlatformConfig.extra`` from env-only setups so ``hermes status`` sees them."""
if not _env_credentials_present():
return None
return _seed_extra_from_env(_ENV_SEED_KEYS, home_env="LINE_HOME_CHANNEL")
async def _standalone_send(
pconfig, chat_id: str, message: str, *,
thread_id: Optional[str] = None, media_files: Optional[List[str]] = None, force_document: bool = False,
) -> Dict[str, Any]:
"""Out-of-process Push delivery for cron jobs detached from the gateway (no inbound event → no
reply token). ``thread_id`` is ignored (no threads); ``media_files`` need the webhook server."""
extra = getattr(pconfig, "extra", {}) or {}
token = _get_scoped_secret("LINE_CHANNEL_ACCESS_TOKEN") or extra.get("channel_access_token", "")
if not token or not chat_id:
return send_error("LINE standalone send: missing token or chat_id")
messages = _text_messages(message or "") or [_text_message("")]
if media_files: # tell the recipient media was generated but not delivered
messages.append(_text_message(f"[{len(media_files)} attachment(s) generated; not deliverable from cron]"))
messages = messages[:LINE_MAX_MESSAGES_PER_CALL]
try:
await _LineClient(token).push(chat_id, messages)
return {"success": True, "message_id": None}
except Exception as exc:
return send_error(str(exc))
_SETUP_PROMPTS = ( # (env var, prompt, masked)
("LINE_CHANNEL_ACCESS_TOKEN", "Channel access token", True),
("LINE_CHANNEL_SECRET", "Channel secret", True),
("LINE_PUBLIC_URL", "Public HTTPS base URL (optional, e.g. https://my-tunnel.example.com)", False),
("LINE_ALLOWED_USERS", "Allowed user IDs (comma-separated; blank=skip)", False))
def interactive_setup() -> None:
"""``hermes setup line`` wizard (writes ``~/.hermes/.env``); CLI helpers are lazy-imported."""
from hermes_cli.config import get_env_value, save_env_value
from hermes_cli.cli_output import print_header, print_info, prompt
from hermes_cli.setup_platforms import declines_reconfigure
print_header("LINE Messaging API")
if declines_reconfigure("LINE", "Reconfigure LINE?", "LINE_CHANNEL_ACCESS_TOKEN"):
return
print_info("Create a Messaging API channel at https://developers.line.biz/console/ then copy the values below.")
for var, question, secret in _SETUP_PROMPTS:
suffix = " [keep current]" if get_env_value(var) else ""
value = prompt(f"{question}{suffix}", password=secret)
if value:
save_env_value(var, value)
print_info("Done. Set the webhook URL in the LINE console to <your-public-url>/line/webhook and enable 'Use webhook'.")
def register(ctx) -> None:
ctx.register_platform(
name="line", label="LINE", adapter_factory=lambda cfg: LineAdapter(cfg), check_fn=check_requirements,
validate_config=validate_config, is_connected=is_connected,
required_env=["LINE_CHANNEL_ACCESS_TOKEN", "LINE_CHANNEL_SECRET"], install_hint="pip install aiohttp",
setup_fn=interactive_setup, env_enablement_fn=_env_enablement, cron_deliver_env_var="LINE_HOME_CHANNEL",
standalone_sender_fn=_standalone_send, allowed_users_env="LINE_ALLOWED_USERS",
allow_all_env="LINE_ALLOW_ALL_USERS",
max_message_length=LINE_SAFE_BUBBLE_CHARS, # per-bubble cap is 5000; smart-chunker uses 4500
emoji="💚", pii_safe=False, allow_update_command=True,
platform_hint=(
"You are chatting via LINE Messaging API. LINE does NOT render "
"Markdown — text bubbles show ** and # literally. Bare URLs are "
"auto-linked, but \\[label\\](url) syntax is not. Each text bubble "
"is capped at 5000 characters and at most 5 bubbles are sent per "
"reply, so keep responses concise. Image/audio/video sending "
"requires LINE_PUBLIC_URL configured to a publicly reachable HTTPS "
"host. Slow responses surface a 'Get answer' button the user taps "
"to fetch the reply via a fresh free token."))
# ---- BEGIN PLUGIN-COMPAT (revert-scheduled; see COMPAT_MANIFEST.md) ----
# Names external plugins imported from this module before the Sep 2026 decomposition.
# Internal code MUST NOT use these (scripts/check_compat_pointers.py fails CI if it does).
# The whole block is removed by reverting the commit that added it.
from dataclasses import field # noqa: F401,E402
# ---- END PLUGIN-COMPAT ----