feat(gateway): add LINE Messaging API platform plugin (#23197)

* feat(gateway): add LINE Messaging API platform plugin

Adds LINE as a bundled platform plugin under `plugins/platforms/line/`,
synthesized from the strongest pieces of seven open community PRs. The
adapter requires zero core edits — `Platform("line")` is auto-discovered
via the bundled-plugin scan in `gateway/config.py`, and all hooks
(setup, env-enablement, cron delivery, standalone send) are wired
through `register_platform()` kwargs the way IRC and Teams do it.

Highlights merged into one plugin:

- **Reply token preferred, Push fallback.** Try the free reply token
  first (single-use, ~60s TTL); fall back to metered Push when the
  token is absent, expired, or rejected. (PR #21023)
- **Slow-LLM Template Buttons postback.** When the LLM is still running
  past `LINE_SLOW_RESPONSE_THRESHOLD` (default 45s), the adapter burns
  the original reply token to send a "Get answer" button bubble. The
  user taps it to fetch the cached answer via a fresh reply token —
  also free. State machine: PENDING → READY → DELIVERED, ERROR for
  cancelled runs (orphan resolves to `LINE_INTERRUPTED_TEXT` after
  /stop). Set threshold to 0 to disable. (PR #18153)
- **Three-allowlist gating** — separate user / group / room allowlists
  with `LINE_ALLOW_ALL_USERS=true` dev-only escape hatch. (PR #18153)
- **Markdown URL preservation.** Strip bold/italic/code-fence/heading
  markers (LINE renders them literally) but keep `[label](url)` →
  `label (url)` so URLs stay tappable. (PR #18153)
- **System-message bypass** for ` Interrupting`, ` Queued`, etc. —
  busy-acks reach the user as visible bubbles instead of being
  swallowed into the postback cache. (PR #18153)
- **Media via public HTTPS URLs.** LINE doesn't accept binary uploads;
  images/audio/video must be HTTPS-reachable. The adapter serves
  registered tempfiles under `/line/media/<token>/<filename>` from the
  same aiohttp app. Allowed-roots traversal guard covers
  `tempfile.gettempdir()`, `/tmp` (→ `/private/tmp` on macOS), and
  `HERMES_HOME`. `LINE_PUBLIC_URL` overrides URL construction for
  setups behind tunnels/proxies. (PR #8398)
- **5-message-per-call batching.** LINE rejects >5 messages per
  Reply/Push; smart-chunker caps text at 4500 chars per bubble.
- **Inbound dedup** via `webhookEventId` LRU. (PR #21023)
- **Self-message filter** via `/v2/bot/info` userId lookup. (PR #21023)
- **Loading-animation indicator** wired to LINE's `chat/loading/start`
  endpoint, DM-only (LINE rejects it for groups/rooms). (PR #21023)
- **Out-of-process cron delivery** via `_standalone_send`, so
  `deliver: line` cron jobs work even when cron runs detached from
  the gateway.
- **Webhook hardening** — 1 MiB body cap, constant-time HMAC-SHA256
  signature verification, dedup, scoped lock so two profiles can't
  bind the same channel.

Validation
----------

- `scripts/run_tests.sh tests/gateway/test_line_plugin.py` →
  73 passed in 1.05s
- `scripts/run_tests.sh tests/gateway/test_line_plugin.py
  tests/gateway/test_irc_adapter.py
  tests/gateway/test_plugin_platform_interface.py
  tests/gateway/test_platform_registry.py
  tests/gateway/test_config.py` → 193 passed, 7 skipped
- E2E import + register + signature roundtrip + `Platform("line")`
  bundled-plugin discovery verified against current `origin/main`.

Closes the seven open LINE PRs (#18153, #16832, #6676, #21023, #14942,
#14988, #8398) by superseding them with a single plugin-form
implementation that takes the best idea from each.

Co-authored-by: pwlee <32443648+leepoweii@users.noreply.github.com>
Co-authored-by: Jetha Chan <jetha@google.com>
Co-authored-by: Cattia <openclaw@liyangchen.me>
Co-authored-by: perng <charles@perng.com>
Co-authored-by: Soichiro Yoshimura <soichiro0111.dev@gmail.com>
Co-authored-by: David Zhou <77736378+David-0x221Eight@users.noreply.github.com>
Co-authored-by: Yu-ga <74749461+yuga-hashimoto@users.noreply.github.com>

* docs(platforms): document platform-specific slow-LLM UX pattern

Add a 'Platform-Specific Slow-LLM UX' section to the platform-adapter
developer guide covering the _keep_typing override pattern that LINE
uses for its Template Buttons postback flow.

Three subsections:
- Pattern: subclass _keep_typing to layer mid-flight UX (with code)
- Pattern: subclass send to route through a cache instead of sending
- When this pattern is appropriate (vs. always-Push fallback)

Plus a short pointer in gateway/platforms/ADDING_A_PLATFORM.md so
tree-readers find the prose walkthrough on the docsite.

Filed because the LINE plugin (PR #23197) was the first bundled
adapter to need this pattern — every prior plugin (irc, teams,
google_chat) handles slow responses with the default typing-loop and
a regular send_text. Documenting now while the rationale is fresh.

---------

Co-authored-by: pwlee <32443648+leepoweii@users.noreply.github.com>
Co-authored-by: Jetha Chan <jetha@google.com>
Co-authored-by: Cattia <openclaw@liyangchen.me>
Co-authored-by: perng <charles@perng.com>
Co-authored-by: Soichiro Yoshimura <soichiro0111.dev@gmail.com>
Co-authored-by: David Zhou <77736378+David-0x221Eight@users.noreply.github.com>
Co-authored-by: Yu-ga <74749461+yuga-hashimoto@users.noreply.github.com>
This commit is contained in:
Teknium 2026-05-10 06:40:46 -07:00 committed by GitHub
parent 9cdcf31cae
commit 50f9fee988
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 2683 additions and 3 deletions

View file

@ -33,6 +33,17 @@ status display, gateway setup, and more.
auto-populate `OPTIONAL_ENV_VARS` in `hermes_cli/config.py` so the setup
wizard surfaces proper descriptions, prompts, password flags, and URLs.
**Subclassing for platform-specific UX.** When a platform has a hard
time-window constraint that the base adapter can't anticipate (LINE's
60s single-use reply token, WhatsApp's 24h session window, etc.), an
adapter can override `_keep_typing` to layer a mid-flight bubble at a
threshold without expanding the kwarg surface. Always
`await super()._keep_typing(...)` so the typing heartbeat keeps running,
and tear down your side task in `finally`. See `plugins/platforms/line/`
for the full pattern (Template Buttons postback at 45s, `RequestCache`
state machine, `interrupt_session_activity` override for `/stop`
orphans) and the developer-guide page for the prose walkthrough.
See `plugins/platforms/irc/`, `plugins/platforms/teams/`, and
`plugins/platforms/google_chat/` for complete working examples, and
`website/docs/developer-guide/adding-platform-adapters.md` for the full

View file

@ -0,0 +1,3 @@
from .adapter import register
__all__ = ["register"]

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,65 @@
name: line-platform
label: LINE
kind: platform
version: 1.0.0
description: >
LINE Messaging API gateway adapter for Hermes Agent.
Runs an aiohttp webhook server that receives LINE webhook events
(with HMAC-SHA256 signature verification) and relays messages between
LINE chats (1:1, groups, rooms) and the Hermes agent. Outbound replies
prefer the free reply token and fall back to the metered Push API
when the token has expired or is absent. Slow LLM responses surface a
Template Buttons postback bubble so the user can fetch the answer with
a fresh reply token (free) once it's ready.
author: Hermes Agent contributors
# ``requires_env`` and ``optional_env`` entries are surfaced in the
# ``hermes config`` UI via the platform-plugin env var injector in
# ``hermes_cli/config.py``.
requires_env:
- name: LINE_CHANNEL_ACCESS_TOKEN
description: "LINE channel long-lived access token (LINE Developers Console > Messaging API > Channel access token)"
prompt: "LINE channel access token"
url: "https://developers.line.biz/console/"
password: true
- name: LINE_CHANNEL_SECRET
description: "LINE channel secret (used for HMAC-SHA256 webhook signature verification)"
prompt: "LINE channel secret"
url: "https://developers.line.biz/console/"
password: true
optional_env:
- name: LINE_PORT
description: "Webhook listen port (default: 8646)"
prompt: "Webhook port"
password: false
- name: LINE_HOST
description: "Webhook bind host (default: 0.0.0.0)"
prompt: "Webhook host"
password: false
- name: LINE_PUBLIC_URL
description: "Public HTTPS base URL for serving images/audio/video to LINE (e.g. https://my-tunnel.example.com). Required for media sending when the bind address is not directly reachable."
prompt: "Public HTTPS base URL"
password: false
- name: LINE_ALLOWED_USERS
description: "Comma-separated LINE user IDs allowed to DM the bot (U-prefixed)"
prompt: "Allowed user IDs (comma-separated)"
password: false
- name: LINE_ALLOWED_GROUPS
description: "Comma-separated LINE group IDs the bot will respond in (C-prefixed)"
prompt: "Allowed group IDs (comma-separated)"
password: false
- name: LINE_ALLOWED_ROOMS
description: "Comma-separated LINE room IDs the bot will respond in (R-prefixed)"
prompt: "Allowed room IDs (comma-separated)"
password: false
- name: LINE_ALLOW_ALL_USERS
description: "Allow any LINE user to talk to the bot (dev only — disables allowlist)"
prompt: "Allow all users? (true/false)"
password: false
- name: LINE_HOME_CHANNEL
description: "Default user/group/room ID for cron / notification delivery"
prompt: "Home channel ID (or empty)"
password: false
- name: LINE_SLOW_RESPONSE_THRESHOLD
description: "Seconds before the slow-LLM postback button fires (default: 45; set 0 to disable and always Push-fallback)"
prompt: "Slow response threshold (seconds)"
password: false

View file

@ -138,6 +138,14 @@ AUTHOR_MAP = {
"tony@tonysimons.dev": "asimons81",
"jetha@google.com": "jethac",
"jani@0xhoneyjar.xyz": "deep-name",
# LINE messaging plugin (synthesis PR)
"32443648+leepoweii@users.noreply.github.com": "leepoweii",
"openclaw@liyangchen.me": "liyoungc",
"charles@perng.com": "perng",
"soichiro0111.dev@gmail.com": "soichiyo",
"0xde@pieverse.io": "David-0x221Eight",
"77736378+David-0x221Eight@users.noreply.github.com": "David-0x221Eight",
"74749461+yuga-hashimoto@users.noreply.github.com": "yuga-hashimoto",
"xiangyong@zspace.cn": "CES4751",
"harish.kukreja@gmail.com": "counterposition",
"35294173+Fearvox@users.noreply.github.com": "Fearvox",

View file

@ -0,0 +1,644 @@
"""Tests for the LINE platform adapter plugin.
Covers the seven synthesis areas from the PR review:
1. webhook signature verification (HMAC-SHA256, base64) + tampering rejection
2. inbound chat-id resolution for user / group / room sources
3. three-allowlist gating (users / groups / rooms / allow_all)
4. inbound dedup via webhookEventId
5. RequestCache state machine (PENDING READY DELIVERED, ERROR)
6. Markdown stripping with URL preservation + LINE-sized chunking
7. send routing: reply token preferred push fallback batched at 5/call
8. register() metadata + standalone_send shape
"""
from __future__ import annotations
import asyncio
import hashlib
import hmac
import base64
import json
import os
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from tests.gateway._plugin_adapter_loader import load_plugin_adapter
# Load plugins/platforms/line/adapter.py under plugin_adapter_line so it
# cannot collide with sibling platform-plugin tests in the same xdist worker.
_line = load_plugin_adapter("line")
verify_line_signature = _line.verify_line_signature
strip_markdown_preserving_urls = _line.strip_markdown_preserving_urls
split_for_line = _line.split_for_line
build_postback_button_message = _line.build_postback_button_message
_resolve_chat = _line._resolve_chat
_allowed_for_source = _line._allowed_for_source
_is_system_bypass = _line._is_system_bypass
RequestCache = _line.RequestCache
State = _line.State
LineAdapter = _line.LineAdapter
register = _line.register
check_requirements = _line.check_requirements
validate_config = _line.validate_config
_standalone_send = _line._standalone_send
_env_enablement = _line._env_enablement
_MessageDeduplicator = _line._MessageDeduplicator
# ---------------------------------------------------------------------------
# 1. Signature verification
# ---------------------------------------------------------------------------
class TestSignature:
def _sign(self, body: bytes, secret: str) -> str:
digest = hmac.new(secret.encode(), body, hashlib.sha256).digest()
return base64.b64encode(digest).decode()
def test_valid_signature_passes(self):
body = b'{"events": []}'
sig = self._sign(body, "secret")
assert verify_line_signature(body, sig, "secret")
def test_tampered_body_rejected(self):
body = b'{"events": []}'
sig = self._sign(body, "secret")
assert not verify_line_signature(body + b" ", sig, "secret")
def test_wrong_secret_rejected(self):
body = b'{"events": []}'
sig = self._sign(body, "secret")
assert not verify_line_signature(body, sig, "different")
def test_empty_signature_rejected(self):
assert not verify_line_signature(b"x", "", "secret")
def test_empty_secret_rejected(self):
assert not verify_line_signature(b"x", "AAAA", "")
def test_garbage_signature_rejected(self):
assert not verify_line_signature(b"hello", "not base64 at all!!", "s")
# ---------------------------------------------------------------------------
# 2. Chat-id / source resolution
# ---------------------------------------------------------------------------
class TestSourceResolution:
def test_user_source(self):
chat_id, ctype = _resolve_chat({"type": "user", "userId": "U123"})
assert chat_id == "U123"
assert ctype == "dm"
def test_group_source(self):
chat_id, ctype = _resolve_chat({"type": "group", "groupId": "C456", "userId": "U123"})
assert chat_id == "C456"
assert ctype == "group"
def test_room_source(self):
chat_id, ctype = _resolve_chat({"type": "room", "roomId": "R789", "userId": "U123"})
assert chat_id == "R789"
assert ctype == "room"
def test_unknown_source_falls_back_to_dm(self):
chat_id, ctype = _resolve_chat({"type": "weird"})
assert chat_id == ""
assert ctype == "dm"
def test_empty_source(self):
chat_id, ctype = _resolve_chat({})
assert chat_id == ""
assert ctype == "dm"
# ---------------------------------------------------------------------------
# 3. Three-allowlist gating
# ---------------------------------------------------------------------------
class TestAllowlist:
def test_allow_all_short_circuits(self):
for src in [
{"type": "user", "userId": "Ufoo"},
{"type": "group", "groupId": "Cfoo"},
{"type": "room", "roomId": "Rfoo"},
]:
assert _allowed_for_source(src, allow_all=True, user_ids=set(), group_ids=set(), room_ids=set())
def test_user_in_allowlist_passes(self):
src = {"type": "user", "userId": "Uok"}
assert _allowed_for_source(src, allow_all=False, user_ids={"Uok"}, group_ids=set(), room_ids=set())
def test_user_not_in_allowlist_rejected(self):
src = {"type": "user", "userId": "Uother"}
assert not _allowed_for_source(src, allow_all=False, user_ids={"Uok"}, group_ids=set(), room_ids=set())
def test_group_uses_group_list_not_user_list(self):
src = {"type": "group", "groupId": "Cok", "userId": "Uany"}
assert _allowed_for_source(src, allow_all=False, user_ids={"Uany"}, group_ids={"Cok"}, room_ids=set())
assert not _allowed_for_source(src, allow_all=False, user_ids={"Uany"}, group_ids=set(), room_ids=set())
def test_room_uses_room_list(self):
src = {"type": "room", "roomId": "Rok"}
assert _allowed_for_source(src, allow_all=False, user_ids=set(), group_ids=set(), room_ids={"Rok"})
assert not _allowed_for_source(src, allow_all=False, user_ids=set(), group_ids=set(), room_ids=set())
def test_unknown_type_rejected(self):
src = {"type": "weird"}
assert not _allowed_for_source(src, allow_all=False, user_ids=set(), group_ids=set(), room_ids=set())
# ---------------------------------------------------------------------------
# 4. Inbound dedup
# ---------------------------------------------------------------------------
class TestDedup:
def test_first_event_not_duplicate(self):
d = _MessageDeduplicator()
assert not d.is_duplicate("evt1")
def test_repeat_event_marked_duplicate(self):
d = _MessageDeduplicator()
d.is_duplicate("evt1")
assert d.is_duplicate("evt1")
def test_blank_id_not_treated_as_duplicate(self):
d = _MessageDeduplicator()
# Blank IDs should always pass through (don't lock out unidentifiable events).
assert not d.is_duplicate("")
assert not d.is_duplicate("")
def test_lru_eviction_under_pressure(self):
d = _MessageDeduplicator(max_size=10)
for i in range(20):
d.is_duplicate(f"evt{i}")
# Exact eviction order isn't specified, but the cap must be enforced.
# Insert one more and assert the bookkeeping doesn't grow without bound.
d.is_duplicate("evt20")
assert len(d._seen) <= 20 # bounded — exact cap depends on eviction policy
# ---------------------------------------------------------------------------
# 5. RequestCache state machine
# ---------------------------------------------------------------------------
class TestRequestCache:
def test_register_pending_is_pending(self):
c = RequestCache()
rid = c.register_pending("Uchat")
assert c.get(rid).state is State.PENDING
assert c.get(rid).chat_id == "Uchat"
def test_set_ready_transitions(self):
c = RequestCache()
rid = c.register_pending("Uchat")
c.set_ready(rid, "the answer")
assert c.get(rid).state is State.READY
assert c.get(rid).payload == "the answer"
def test_set_error_transitions(self):
c = RequestCache()
rid = c.register_pending("Uchat")
c.set_error(rid, "boom")
assert c.get(rid).state is State.ERROR
assert c.get(rid).payload == "boom"
def test_mark_delivered_from_ready(self):
c = RequestCache()
rid = c.register_pending("Uchat")
c.set_ready(rid, "x")
c.mark_delivered(rid)
assert c.get(rid).state is State.DELIVERED
def test_mark_delivered_from_error(self):
c = RequestCache()
rid = c.register_pending("Uchat")
c.set_error(rid, "x")
c.mark_delivered(rid)
assert c.get(rid).state is State.DELIVERED
def test_set_ready_on_delivered_is_noop(self):
c = RequestCache()
rid = c.register_pending("Uchat")
c.set_ready(rid, "first")
c.mark_delivered(rid)
c.set_ready(rid, "second")
# DELIVERED is terminal — no further mutation
assert c.get(rid).payload == "first"
assert c.get(rid).state is State.DELIVERED
def test_find_pending_for_chat(self):
c = RequestCache()
rid_a = c.register_pending("Ua")
rid_b = c.register_pending("Ub")
assert c.find_pending_for_chat("Ua") == rid_a
assert c.find_pending_for_chat("Ub") == rid_b
assert c.find_pending_for_chat("Uc") is None
c.set_ready(rid_a, "x")
# No longer PENDING — should not be found
assert c.find_pending_for_chat("Ua") is None
# ---------------------------------------------------------------------------
# 6. Markdown stripping + chunking
# ---------------------------------------------------------------------------
class TestMarkdownAndChunking:
def test_bold_stripped(self):
assert strip_markdown_preserving_urls("**hello**") == "hello"
def test_italic_stripped(self):
assert strip_markdown_preserving_urls("*hello*") == "hello"
def test_inline_code_unfenced(self):
assert strip_markdown_preserving_urls("run `ls -la`") == "run ls -la"
def test_link_preserved_with_url(self):
out = strip_markdown_preserving_urls("see [here](https://x.com)")
assert "https://x.com" in out
assert "here (https://x.com)" in out
def test_heading_prefix_stripped(self):
out = strip_markdown_preserving_urls("# Title\n## Sub")
assert out == "Title\nSub"
def test_bullet_marker_replaced(self):
out = strip_markdown_preserving_urls("- a\n- b")
assert out == "• a\n• b"
def test_code_fence_content_kept(self):
# Source files often contain code snippets — the agent should still
# see the content as plain text, just without backticks.
md = "```python\nprint('hi')\n```"
out = strip_markdown_preserving_urls(md)
assert "print('hi')" in out
assert "```" not in out
def test_split_short_returns_single_chunk(self):
assert split_for_line("hi") == ["hi"]
def test_split_long_chunks_at_paragraph_boundary(self):
text = "para1\n\npara2\n\npara3"
chunks = split_for_line(text, max_chars=8)
assert all(len(c) <= 8 for c in chunks), chunks
assert len(chunks) >= 2
def test_split_caps_at_five_chunks(self):
# 1000 paragraphs of 100 chars each — must cap at 5 LINE bubbles.
text = "\n\n".join(["x" * 100 for _ in range(1000)])
chunks = split_for_line(text)
assert len(chunks) <= 5
# ---------------------------------------------------------------------------
# 7. Send routing (reply -> push fallback, batching, system-bypass)
# ---------------------------------------------------------------------------
class TestSendRouting:
@pytest.fixture
def adapter(self, monkeypatch):
monkeypatch.delenv("LINE_CHANNEL_ACCESS_TOKEN", raising=False)
monkeypatch.delenv("LINE_CHANNEL_SECRET", raising=False)
from gateway.config import PlatformConfig
cfg = PlatformConfig(enabled=True, extra={
"channel_access_token": "tok",
"channel_secret": "sec",
})
ad = LineAdapter(cfg)
ad._client = MagicMock()
ad._client.reply = AsyncMock()
ad._client.push = AsyncMock()
return ad
def test_system_bypass_recognized(self):
assert _is_system_bypass("⚡ Interrupting current run")
assert _is_system_bypass("⏳ Queued — agent is busy")
assert _is_system_bypass("⏩ Steered toward new task")
assert not _is_system_bypass("Hello world")
assert not _is_system_bypass("")
def test_send_uses_reply_when_token_present(self, adapter):
import time as _time
adapter._reply_tokens["Uchat"] = ("rt-token", _time.time() + 30)
result = asyncio.run(adapter.send("Uchat", "hello"))
assert result.success
adapter._client.reply.assert_called_once()
adapter._client.push.assert_not_called()
# Token consumed (single-use)
assert "Uchat" not in adapter._reply_tokens
def test_send_falls_back_to_push_when_no_token(self, adapter):
result = asyncio.run(adapter.send("Uchat", "hello"))
assert result.success
adapter._client.push.assert_called_once()
adapter._client.reply.assert_not_called()
def test_send_falls_back_to_push_when_reply_fails(self, adapter):
import time as _time
adapter._reply_tokens["Uchat"] = ("rt-token", _time.time() + 30)
adapter._client.reply.side_effect = RuntimeError("expired")
result = asyncio.run(adapter.send("Uchat", "hello"))
assert result.success
adapter._client.reply.assert_called_once()
adapter._client.push.assert_called_once()
def test_send_returns_failure_when_push_fails(self, adapter):
adapter._client.push.side_effect = RuntimeError("network")
result = asyncio.run(adapter.send("Uchat", "hello"))
assert not result.success
assert "network" in result.error
def test_send_pending_button_caches_response(self, adapter):
# Simulate that the slow-LLM postback button has fired.
rid = adapter._cache.register_pending("Uchat")
adapter._pending_buttons["Uchat"] = rid
result = asyncio.run(adapter.send("Uchat", "the answer"))
assert result.success
# Response must have been cached, not pushed/replied.
adapter._client.reply.assert_not_called()
adapter._client.push.assert_not_called()
assert adapter._cache.get(rid).state is State.READY
assert adapter._cache.get(rid).payload == "the answer"
def test_send_system_bypass_skips_postback_cache(self, adapter):
# Even with a pending button, system busy-acks must surface visibly.
rid = adapter._cache.register_pending("Uchat")
adapter._pending_buttons["Uchat"] = rid
result = asyncio.run(adapter.send("Uchat", "⚡ Interrupting current run"))
assert result.success
# Bypass goes through push (no reply token stored)
adapter._client.push.assert_called_once()
# And the cache entry is unchanged (still PENDING for the eventual answer)
assert adapter._cache.get(rid).state is State.PENDING
def test_send_caps_messages_per_call_at_five(self, adapter):
# Build a payload that would naturally split into more than 5 LINE
# bubbles; the chunker should cap at 5 + truncate.
big = "\n\n".join(["x" * 4500 for _ in range(20)])
result = asyncio.run(adapter.send("Uchat", big))
assert result.success
call_kwargs = adapter._client.push.call_args
# call_args is (args, kwargs); for our send the messages are the 2nd positional
sent_messages = call_kwargs.args[1] if call_kwargs.args else call_kwargs.kwargs.get("messages")
# Without args, fall back to inspecting the call shape
if sent_messages is None:
# We invoked client.push(chat_id, messages) — check first batch
sent_messages = adapter._client.push.call_args.args[1]
assert len(sent_messages) <= 5
def test_format_message_strips_markdown(self, adapter):
out = adapter.format_message("**bold** [link](https://x.com)")
assert "**" not in out
assert "https://x.com" in out
# ---------------------------------------------------------------------------
# 8. Register() metadata + plugin entry points
# ---------------------------------------------------------------------------
class TestRegister:
class _FakeCtx:
def __init__(self):
self.kwargs = None
def register_platform(self, **kw):
self.kwargs = kw
def test_register_calls_register_platform(self):
ctx = self._FakeCtx()
register(ctx)
assert ctx.kwargs is not None
assert ctx.kwargs["name"] == "line"
assert ctx.kwargs["label"] == "LINE"
def test_register_advertises_required_env(self):
ctx = self._FakeCtx()
register(ctx)
assert set(ctx.kwargs["required_env"]) == {
"LINE_CHANNEL_ACCESS_TOKEN",
"LINE_CHANNEL_SECRET",
}
def test_register_wires_allowlist_envs(self):
ctx = self._FakeCtx()
register(ctx)
assert ctx.kwargs["allowed_users_env"] == "LINE_ALLOWED_USERS"
assert ctx.kwargs["allow_all_env"] == "LINE_ALLOW_ALL_USERS"
def test_register_wires_cron_home_channel(self):
ctx = self._FakeCtx()
register(ctx)
assert ctx.kwargs["cron_deliver_env_var"] == "LINE_HOME_CHANNEL"
def test_register_provides_standalone_sender(self):
ctx = self._FakeCtx()
register(ctx)
assert callable(ctx.kwargs["standalone_sender_fn"])
def test_register_provides_env_enablement(self):
ctx = self._FakeCtx()
register(ctx)
assert callable(ctx.kwargs["env_enablement_fn"])
def test_register_factory_yields_line_adapter(self):
ctx = self._FakeCtx()
register(ctx)
from gateway.config import PlatformConfig
cfg = PlatformConfig(enabled=True, extra={
"channel_access_token": "tok",
"channel_secret": "sec",
})
ad = ctx.kwargs["adapter_factory"](cfg)
assert isinstance(ad, LineAdapter)
def test_max_message_length_below_line_per_bubble_limit(self):
ctx = self._FakeCtx()
register(ctx)
# LINE per-bubble limit is 5000; we register 4500 to leave headroom.
assert ctx.kwargs["max_message_length"] <= 5000
class TestEnvEnablement:
def test_returns_none_without_credentials(self, monkeypatch):
monkeypatch.delenv("LINE_CHANNEL_ACCESS_TOKEN", raising=False)
monkeypatch.delenv("LINE_CHANNEL_SECRET", raising=False)
assert _env_enablement() is None
def test_returns_dict_with_credentials(self, monkeypatch):
monkeypatch.setenv("LINE_CHANNEL_ACCESS_TOKEN", "tok")
monkeypatch.setenv("LINE_CHANNEL_SECRET", "sec")
assert _env_enablement() == {}
def test_seeds_port_from_env(self, monkeypatch):
monkeypatch.setenv("LINE_CHANNEL_ACCESS_TOKEN", "tok")
monkeypatch.setenv("LINE_CHANNEL_SECRET", "sec")
monkeypatch.setenv("LINE_PORT", "8080")
assert _env_enablement() == {"port": 8080}
def test_seeds_public_url(self, monkeypatch):
monkeypatch.setenv("LINE_CHANNEL_ACCESS_TOKEN", "tok")
monkeypatch.setenv("LINE_CHANNEL_SECRET", "sec")
monkeypatch.setenv("LINE_PUBLIC_URL", "https://my-tunnel.example.com")
result = _env_enablement()
assert result["public_url"] == "https://my-tunnel.example.com"
class TestStandaloneSend:
def test_missing_token_returns_error(self, monkeypatch):
monkeypatch.delenv("LINE_CHANNEL_ACCESS_TOKEN", raising=False)
from gateway.config import PlatformConfig
cfg = PlatformConfig(enabled=True, extra={})
result = asyncio.run(_standalone_send(cfg, "Uchat", "hi"))
assert "error" in result
def test_missing_chat_id_returns_error(self, monkeypatch):
monkeypatch.setenv("LINE_CHANNEL_ACCESS_TOKEN", "tok")
from gateway.config import PlatformConfig
cfg = PlatformConfig(enabled=True, extra={})
result = asyncio.run(_standalone_send(cfg, "", "hi"))
assert "error" in result
def test_pushes_via_client_when_credentials_present(self, monkeypatch):
from gateway.config import PlatformConfig
push_calls = []
class _FakeClient:
def __init__(self, *a, **kw):
pass
async def push(self, chat_id, messages):
push_calls.append((chat_id, messages))
monkeypatch.setattr(_line, "_LineClient", _FakeClient)
cfg = PlatformConfig(
enabled=True,
extra={"channel_access_token": "tok"},
)
result = asyncio.run(_standalone_send(cfg, "Uchat", "hello"))
assert result.get("success") is True
assert len(push_calls) == 1
assert push_calls[0][0] == "Uchat"
# Message wraps as text bubble
assert push_calls[0][1][0]["type"] == "text"
class TestPostbackButtonShape:
def test_template_buttons_structure(self):
msg = build_postback_button_message("hi", "Tap me", "rid-1")
assert msg["type"] == "template"
assert msg["template"]["type"] == "buttons"
assert msg["template"]["text"] == "hi"
actions = msg["template"]["actions"]
assert len(actions) == 1
assert actions[0]["type"] == "postback"
data = json.loads(actions[0]["data"])
assert data == {"action": "show_response", "request_id": "rid-1"}
def test_text_truncated_to_160(self):
long = "x" * 200
msg = build_postback_button_message(long, "Tap", "rid")
assert len(msg["template"]["text"]) <= 160
def test_alt_text_truncated_to_400(self):
long = "x" * 500
msg = build_postback_button_message(long, "Tap", "rid")
assert len(msg["altText"]) <= 400
class TestCheckRequirements:
def test_rejects_without_token(self, monkeypatch):
monkeypatch.delenv("LINE_CHANNEL_ACCESS_TOKEN", raising=False)
monkeypatch.setenv("LINE_CHANNEL_SECRET", "s")
assert not check_requirements()
def test_rejects_without_secret(self, monkeypatch):
monkeypatch.setenv("LINE_CHANNEL_ACCESS_TOKEN", "t")
monkeypatch.delenv("LINE_CHANNEL_SECRET", raising=False)
assert not check_requirements()
class TestValidateConfig:
def test_validates_from_extra(self):
from gateway.config import PlatformConfig
cfg = PlatformConfig(
enabled=True,
extra={"channel_access_token": "t", "channel_secret": "s"},
)
assert validate_config(cfg)
def test_rejects_empty_config(self, monkeypatch):
monkeypatch.delenv("LINE_CHANNEL_ACCESS_TOKEN", raising=False)
monkeypatch.delenv("LINE_CHANNEL_SECRET", raising=False)
from gateway.config import PlatformConfig
cfg = PlatformConfig(enabled=True, extra={})
assert not validate_config(cfg)
class TestAdapterInit:
def test_init_from_config_extra(self, monkeypatch):
for k in ("LINE_CHANNEL_ACCESS_TOKEN", "LINE_CHANNEL_SECRET", "LINE_PORT"):
monkeypatch.delenv(k, raising=False)
from gateway.config import PlatformConfig
cfg = PlatformConfig(
enabled=True,
extra={
"channel_access_token": "tok",
"channel_secret": "sec",
"port": 7777,
"public_url": "https://x.example.com",
"allowed_users": ["U1", "U2"],
},
)
ad = LineAdapter(cfg)
assert ad.channel_access_token == "tok"
assert ad.channel_secret == "sec"
assert ad.webhook_port == 7777
assert ad.public_base_url == "https://x.example.com"
assert ad.allowed_users == {"U1", "U2"}
def test_env_overrides_extra(self, monkeypatch):
monkeypatch.setenv("LINE_CHANNEL_ACCESS_TOKEN", "env-tok")
monkeypatch.setenv("LINE_PORT", "1234")
from gateway.config import PlatformConfig
cfg = PlatformConfig(
enabled=True,
extra={"channel_access_token": "extra-tok", "channel_secret": "s", "port": 5555},
)
ad = LineAdapter(cfg)
assert ad.channel_access_token == "env-tok"
assert ad.webhook_port == 1234
def test_csv_allowlist_parsed(self, monkeypatch):
monkeypatch.setenv("LINE_CHANNEL_ACCESS_TOKEN", "t")
monkeypatch.setenv("LINE_CHANNEL_SECRET", "s")
monkeypatch.setenv("LINE_ALLOWED_USERS", "U1, U2,U3")
monkeypatch.setenv("LINE_ALLOWED_GROUPS", "C1")
from gateway.config import PlatformConfig
ad = LineAdapter(PlatformConfig(enabled=True))
assert ad.allowed_users == {"U1", "U2", "U3"}
assert ad.allowed_groups == {"C1"}
def test_get_chat_info_infers_type_from_prefix(self, monkeypatch):
monkeypatch.setenv("LINE_CHANNEL_ACCESS_TOKEN", "t")
monkeypatch.setenv("LINE_CHANNEL_SECRET", "s")
from gateway.config import PlatformConfig
ad = LineAdapter(PlatformConfig(enabled=True))
assert asyncio.run(ad.get_chat_info("U123"))["type"] == "dm"
assert asyncio.run(ad.get_chat_info("C123"))["type"] == "group"
assert asyncio.run(ad.get_chat_info("R123"))["type"] == "channel"

View file

@ -322,9 +322,98 @@ optional_env:
Bare-string entries (`- MY_PLATFORM_TOKEN`) still work — they get a generic description auto-derived from the plugin's `label`. If a hardcoded entry for the same var already exists in `OPTIONAL_ENV_VARS`, it wins (back-compat); the plugin.yaml form acts as the fallback.
## Platform-Specific Slow-LLM UX
Some platforms have constraints that change how a slow LLM response should be presented:
- **LINE** issues a single-use *reply token* that expires roughly 60 seconds after the inbound event. Replying with that token is free; falling back to the metered Push API is not. If the LLM hasn't finished by the deadline, the choice is "burn paid Push quota" or "do something cleverer with the reply token before it expires."
- **WhatsApp** marks a session inactive after 24h, after which only template messages are accepted.
- **SMS** has no concept of typing indicators or progressive updates — long responses just look like the bot is offline.
These are real constraints the base `BasePlatformAdapter` can't anticipate. The plugin surface intentionally leaves the room for an adapter to layer platform-specific UX on top of the base typing loop without expanding the kwarg list.
### Pattern: subclass `_keep_typing` to layer mid-flight UX
`BasePlatformAdapter._keep_typing` is the typing-indicator heartbeat — it runs as a background task while the LLM is generating, and is cancelled when the response is delivered. To layer a platform-specific behavior at a threshold (e.g. send a "still thinking" bubble at 45s), override `_keep_typing` in your adapter, schedule your own task alongside `super()._keep_typing()`, and tear it down in `finally`:
```python
class LineAdapter(BasePlatformAdapter):
async def _keep_typing(self, chat_id: str, *args, **kwargs) -> None:
if self.slow_response_threshold <= 0:
await super()._keep_typing(chat_id, *args, **kwargs)
return
async def _fire_at_threshold() -> None:
try:
await asyncio.sleep(self.slow_response_threshold)
except asyncio.CancelledError:
raise
# Platform-specific work here — for LINE, send a Template
# Buttons "Get answer" bubble using the cached reply token
# so the user can fetch the cached response later via a
# fresh (free) reply token from the postback callback.
await self._send_slow_response_button(chat_id)
side_task = asyncio.create_task(_fire_at_threshold())
try:
await super()._keep_typing(chat_id, *args, **kwargs)
finally:
if not side_task.done():
side_task.cancel()
try:
await side_task
except (asyncio.CancelledError, Exception):
pass
```
Key points:
- **Always `await super()._keep_typing(...)`.** The typing heartbeat is independently useful — don't replace it, layer on top of it.
- **Tear down the side task in `finally`.** When the LLM finishes (or `/stop` cancels the run), the gateway cancels the typing task. Your side task must observe that cancellation too, otherwise it lingers and may fire after the response was already delivered.
- **Pair with `interrupt_session_activity`** to resolve any orphan UX state when the user issues `/stop`. For LINE, this means transitioning the postback cache entry from `PENDING` to `ERROR` so the persistent "Get answer" button delivers a "Run was interrupted" message instead of looping.
### Pattern: subclass `send` to route through a cache instead of sending immediately
If your slow-response UX caches the response for later retrieval (LINE's postback flow), your `send` override needs to recognize three modes:
1. **Pending postback active for this chat** → cache the response under the request_id, don't send anything visible.
2. **System busy-ack** (`⚡ Interrupting`, `⏳ Queued`, `⏩ Steered`) → bypass the cache and send visibly so the user sees the gateway's response to their input.
3. **Normal response** → send via reply-token-or-push as usual.
```python
async def send(self, chat_id: str, content: str, **kw) -> SendResult:
if _is_system_bypass(content):
return await self._send_text_chunks(chat_id, content, force_push=False)
pending_rid = self._pending_buttons.get(chat_id)
if pending_rid:
self._cache.set_ready(pending_rid, content)
return SendResult(success=True, message_id=pending_rid)
return await self._send_text_chunks(chat_id, content, force_push=False)
```
`_SYSTEM_BYPASS_PREFIXES` are the gateway's own busy-acknowledgment prefixes (`⚡`, `⏳`, `⏩`, `💾`). Always let those through visibly, regardless of cached UX state.
### When this pattern is appropriate
Use the typing-loop override approach when:
- The platform's outbound API has a hard time-window constraint (single-use reply token, expiring sticky session, etc.) AND
- A *visible mid-flight bubble* is acceptable UX on that platform.
Use the simpler `slow_response_threshold = 0` always-Push path when:
- The platform doesn't have a meaningful free vs. paid distinction, OR
- The user community prefers "loading… loading… DONE" silence-then-response over an interactive intermediate bubble.
LINE supports both: the threshold defaults to 45s for free postback fetch, and `LINE_SLOW_RESPONSE_THRESHOLD=0` reverts to "always Push fallback."
### Reference Implementation
See `plugins/platforms/irc/` in the repo for a complete working example — a full async IRC adapter with zero external dependencies.
See `plugins/platforms/line/adapter.py` for the full LINE postback implementation — a `RequestCache` state machine (`PENDING → READY → DELIVERED`, plus `ERROR` for `/stop`), a `_keep_typing` override that fires the Template Buttons bubble at threshold, a `send` override that routes through the cache, and an `interrupt_session_activity` override that resolves orphan PENDING entries.
### Reference Implementations (Plugin Path)
See `plugins/platforms/irc/` in the repo for a complete working example — a full async IRC adapter with zero external dependencies. `plugins/platforms/teams/` covers Bot Framework / Adaptive Cards, `plugins/platforms/google_chat/` covers OAuth-based REST APIs, and `plugins/platforms/line/` covers webhook-driven Messaging APIs with platform-specific slow-LLM UX.
---

View file

@ -443,6 +443,28 @@ Only used when the [`teams_pipeline` plugin](/docs/user-guide/messaging/msgraph-
| `TEAMS_CHANNEL_ID` | Target channel ID (paired with `TEAMS_TEAM_ID`). |
| `TEAMS_CHAT_ID` | Target 1:1 or group chat ID (alternative to team+channel for `graph` mode). |
### LINE Messaging API
Used by the bundled LINE platform plugin (`plugins/platforms/line/`). See [Messaging Gateway → LINE](/docs/user-guide/messaging/line) for full setup.
| Variable | Description |
|----------|-------------|
| `LINE_CHANNEL_ACCESS_TOKEN` | Long-lived channel access token from the LINE Developers Console (Messaging API tab). Required. |
| `LINE_CHANNEL_SECRET` | Channel secret (Basic settings tab); used for HMAC-SHA256 webhook signature verification. Required. |
| `LINE_HOST` | Webhook bind host (default: `0.0.0.0`). |
| `LINE_PORT` | Webhook bind port (default: `8646`). |
| `LINE_PUBLIC_URL` | Public HTTPS base URL (e.g. `https://my-tunnel.example.com`). Required for image / audio / video sends — LINE only accepts HTTPS-reachable URLs. |
| `LINE_ALLOWED_USERS` | Comma-separated user IDs allowed to DM the bot (`U`-prefixed). |
| `LINE_ALLOWED_GROUPS` | Comma-separated group IDs the bot will respond in (`C`-prefixed). |
| `LINE_ALLOWED_ROOMS` | Comma-separated room IDs the bot will respond in (`R`-prefixed). |
| `LINE_ALLOW_ALL_USERS` | Dev-only escape hatch — accepts any source. Default: `false`. |
| `LINE_HOME_CHANNEL` | Default delivery target for cron jobs with `deliver: line`. |
| `LINE_SLOW_RESPONSE_THRESHOLD` | Seconds before the slow-LLM Template Buttons postback fires (default: `45`). Set `0` to disable and always Push-fallback. |
| `LINE_PENDING_TEXT` | Bubble text shown alongside the postback button. |
| `LINE_BUTTON_LABEL` | Postback button label (default: `Get answer`). |
| `LINE_DELIVERED_TEXT` | Reply when an already-delivered postback is tapped again (default: `Already replied ✅`). |
| `LINE_INTERRUPTED_TEXT` | Reply when a `/stop`-orphaned postback button is tapped (default: `Run was interrupted before completion.`). |
### Advanced Messaging Tuning
Advanced per-platform knobs for throttling the outbound message batcher. Most users never need to touch these; defaults are set to respect each platform's rate limits without feeling sluggish.

View file

@ -1,12 +1,12 @@
---
sidebar_position: 1
title: "Messaging Gateway"
description: "Chat with Hermes from Telegram, Discord, Slack, WhatsApp, Signal, SMS, Email, Home Assistant, Mattermost, Matrix, DingTalk, Yuanbao, Microsoft Teams, Webhooks, or any OpenAI-compatible frontend via the API server — architecture and setup overview"
description: "Chat with Hermes from Telegram, Discord, Slack, WhatsApp, Signal, SMS, Email, Home Assistant, Mattermost, Matrix, DingTalk, Yuanbao, Microsoft Teams, LINE, Webhooks, or any OpenAI-compatible frontend via the API server — architecture and setup overview"
---
# Messaging Gateway
Chat with Hermes from Telegram, Discord, Slack, WhatsApp, Signal, SMS, Email, Home Assistant, Mattermost, Matrix, DingTalk, Feishu/Lark, WeCom, Weixin, BlueBubbles (iMessage), QQ, Yuanbao, Microsoft Teams, 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, WeCom, Weixin, BlueBubbles (iMessage), QQ, Yuanbao, Microsoft Teams, LINE, 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).
@ -34,6 +34,7 @@ For the full voice feature set — including CLI microphone mode, spoken replies
| QQ | ✅ | ✅ | ✅ | — | — | ✅ | — |
| Yuanbao | ✅ | ✅ | ✅ | — | — | ✅ | ✅ |
| Microsoft Teams | — | ✅ | — | ✅ | — | ✅ | — |
| LINE | — | ✅ | ✅ | — | — | ✅ | — |
**Voice** = TTS audio replies and/or voice message transcription. **Images** = send/receive images. **Files** = send/receive file attachments. **Threads** = threaded conversations. **Reactions** = emoji reactions on messages. **Typing** = typing indicator while processing. **Streaming** = progressive message updates via editing.

View file

@ -0,0 +1,198 @@
---
sidebar_position: 17
title: "LINE"
description: "Set up Hermes Agent as a LINE Messaging API bot"
---
# LINE Setup
Run Hermes Agent as a [LINE](https://line.me/) bot via the official LINE Messaging API. The adapter lives as a bundled platform plugin under `plugins/platforms/line/` — no core edits, just enable it like any other platform.
LINE is the dominant messaging app in Japan, Taiwan, and Thailand. If your users live there, this is how they reach you.
## How the bot responds
| Context | Behavior |
|---------|----------|
| **1:1 chat** (`U` IDs) | Responds to every message |
| **Group chat** (`C` IDs) | Responds when the group is on the allowlist |
| **Multi-user room** (`R` IDs) | Responds when the room is on the allowlist |
Inbound text, images, audio, video, files, stickers, and locations are all handled. Outbound text uses the **free reply token first** (single-use, ~60s window) and falls back to the metered Push API when the token has expired.
---
## Step 1: Create a LINE Messaging API channel
1. Go to the [LINE Developers Console](https://developers.line.biz/console/).
2. Create a Provider, then under it a **Messaging API** channel.
3. From the channel's **Basic settings** tab, copy the **Channel secret**.
4. From the **Messaging API** tab, scroll to **Channel access token (long-lived)** and click **Issue**. Copy the token.
5. In the **Messaging API** tab, also disable **Auto-reply messages** and **Greeting messages** so they don't fight your bot's replies.
---
## Step 2: Expose the webhook port
LINE delivers webhooks over public HTTPS. The default port is `8646` — override with `LINE_PORT` if needed.
```bash
# Cloudflare Tunnel (recommended for production — fixed hostname)
cloudflared tunnel --url http://localhost:8646
# ngrok (good for dev)
ngrok http 8646
# devtunnel
devtunnel create hermes-line --allow-anonymous
devtunnel port create hermes-line -p 8646 --protocol https
devtunnel host hermes-line
```
Copy the `https://...` URL — you'll set it as the webhook URL below. **Leave the tunnel running** while testing. For production, set up a fixed Cloudflare named tunnel so the webhook URL doesn't change on restart.
---
## Step 3: Configure Hermes
Add to `~/.hermes/.env`:
```env
LINE_CHANNEL_ACCESS_TOKEN=YOUR_LONG_LIVED_TOKEN
LINE_CHANNEL_SECRET=YOUR_CHANNEL_SECRET
# Allowlist — at least one of these (or LINE_ALLOW_ALL_USERS=true for dev)
LINE_ALLOWED_USERS=U1234567890abcdef... # comma-separated U-prefixed IDs
LINE_ALLOWED_GROUPS=C1234567890abcdef... # optional group IDs
LINE_ALLOWED_ROOMS=R1234567890abcdef... # optional room IDs
# Required for image / audio / video sends — the public HTTPS base URL
# the tunnel resolves to. Without it, send_image/voice/video will refuse.
LINE_PUBLIC_URL=https://my-tunnel.example.com
```
Then in `~/.hermes/config.yaml`:
```yaml
gateway:
platforms:
line:
enabled: true
```
That's enough — the bundled-plugin scan in `gateway/config.py` automatically picks up `plugins/platforms/line/`. No `Platform.LINE` enum edit, no `_create_adapter` registration.
---
## Step 4: Set the webhook URL
Back in the LINE console:
1. Open your channel → **Messaging API** tab.
2. Under **Webhook settings****Webhook URL**, paste `https://<your-tunnel>/line/webhook` (note the `/line/webhook` path — the adapter listens there).
3. Click **Verify**. LINE pings the URL; you should see a 200.
4. Toggle **Use webhook** to **On**.
---
## Step 5: Run the gateway
```bash
hermes gateway
```
The agent log shows:
```
LINE: webhook listening on 0.0.0.0:8646/line/webhook (public: https://my-tunnel.example.com)
```
Add the bot as a friend from the LINE app (scan the QR in the channel's **Messaging API** tab) and send it a message.
---
## Slow LLM responses
LINE's reply token is single-use and expires roughly 60 seconds after the inbound event. Slow LLMs can't reply in time, which would normally force a paid Push API call.
When the LLM is still running past `LINE_SLOW_RESPONSE_THRESHOLD` seconds (default `45`), the adapter consumes the original reply token to send a **Template Buttons** bubble:
> 🤔 Still thinking. Tap below to fetch the answer when it's ready.
>
> [ Get answer ]
The user taps **Get answer** when convenient — that postback delivers a *fresh* reply token, which the adapter uses to send the cached answer (still free).
State machine: `PENDING → READY → DELIVERED`, plus `ERROR` for cancelled runs (the orphan PENDING resolves to "Run was interrupted before completion." after `/stop` so the persistent button doesn't loop).
To disable the postback button and always Push-fallback instead:
```env
LINE_SLOW_RESPONSE_THRESHOLD=0
```
For the postback flow to fire reliably, suppress chatter that would consume the reply token before the threshold:
```yaml
# ~/.hermes/config.yaml
display:
interim_assistant_messages: false
platforms:
line:
tool_progress: off
```
---
## Cron / notification delivery
```env
LINE_HOME_CHANNEL=Uxxxxxxxxxxxxxxxxxxxx # default delivery target
```
Cron jobs with `deliver: line` route to `LINE_HOME_CHANNEL`. The adapter ships a standalone Push-only sender so cron jobs work even when cron runs in a separate process from the gateway.
---
## Environment variable reference
| Variable | Required | Default | Description |
|---|---|---|---|
| `LINE_CHANNEL_ACCESS_TOKEN` | yes | — | Long-lived channel access token |
| `LINE_CHANNEL_SECRET` | yes | — | Channel secret (HMAC-SHA256 webhook verification) |
| `LINE_HOST` | no | `0.0.0.0` | Webhook bind host |
| `LINE_PORT` | no | `8646` | Webhook bind port |
| `LINE_PUBLIC_URL` | for media | — | Public HTTPS base URL; required for image/voice/video sends |
| `LINE_ALLOWED_USERS` | one of | — | Comma-separated user IDs (U-prefixed) |
| `LINE_ALLOWED_GROUPS` | one of | — | Comma-separated group IDs (C-prefixed) |
| `LINE_ALLOWED_ROOMS` | one of | — | Comma-separated room IDs (R-prefixed) |
| `LINE_ALLOW_ALL_USERS` | dev only | `false` | Skip allowlist entirely |
| `LINE_HOME_CHANNEL` | no | — | Default cron / notification delivery target |
| `LINE_SLOW_RESPONSE_THRESHOLD` | no | `45` | Seconds before the postback button fires (`0` = disabled) |
| `LINE_PENDING_TEXT` | no | "🤔 Still thinking…" | Bubble text shown alongside the postback button |
| `LINE_BUTTON_LABEL` | no | "Get answer" | Button label |
| `LINE_DELIVERED_TEXT` | no | "Already replied ✅" | Reply when an already-delivered button is tapped again |
| `LINE_INTERRUPTED_TEXT` | no | "Run was interrupted before completion." | Reply when a `/stop` orphan button is tapped |
---
## Troubleshooting
**"invalid signature" on webhook verify.** The `Channel secret` was copied wrong, or your tunnel rewrote the request body. Verify with `curl -i https://<tunnel>/line/webhook/health` first — that should return `{"status":"ok","platform":"line"}`.
**Bot receives nothing in groups.** Check `LINE_ALLOWED_GROUPS` includes the `C...` group ID. To find a group ID, send a test message and grep `~/.hermes/logs/gateway.log` for `LINE: rejecting unauthorized source` — the rejected source dict has the IDs.
**`send_image` fails with "LINE_PUBLIC_URL must be set".** LINE's Messaging API does not accept binary uploads — images, audio, and video must be reachable HTTPS URLs. Set `LINE_PUBLIC_URL` to the tunnel's public hostname and the adapter will serve files from `/line/media/<token>/<filename>` automatically.
**Postback button never appears.** Either the LLM responded faster than `LINE_SLOW_RESPONSE_THRESHOLD`, or another bubble (tool-progress, streaming) consumed the reply token first. See the suppression block under "Slow LLM responses".
**"already in use by another profile".** The same channel access token is bound to another running Hermes profile. Stop the other gateway or use a separate channel.
---
## Limitations
* **Single bubble per chunk.** Each LINE text bubble is capped at 5000 characters, and at most 5 bubbles are sent per Reply/Push call. Longer responses are truncated with an ellipsis.
* **No native message editing.** LINE has no edit-message API — streaming responses always send fresh bubbles, never edit prior ones.
* **No Markdown rendering.** Bold (`**`), italics (`*`), code fences, and headings render as literal characters. The adapter strips them before sending; URLs are preserved (`[label](url)` becomes `label (url)`).
* **Loading indicator is DM-only.** LINE rejects the chat/loading API for groups and rooms, so the typing indicator only shows in 1:1 chats.

View file

@ -141,6 +141,7 @@ const sidebars: SidebarsConfig = {
'user-guide/messaging/teams',
'user-guide/messaging/teams-meetings',
'user-guide/messaging/msgraph-webhook',
'user-guide/messaging/line',
'user-guide/messaging/open-webui',
'user-guide/messaging/webhooks',
],