mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-05-18 04:41:56 +00:00
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:
parent
9cdcf31cae
commit
50f9fee988
11 changed files with 2683 additions and 3 deletions
|
|
@ -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
|
||||
|
|
|
|||
3
plugins/platforms/line/__init__.py
Normal file
3
plugins/platforms/line/__init__.py
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
from .adapter import register
|
||||
|
||||
__all__ = ["register"]
|
||||
1638
plugins/platforms/line/adapter.py
Normal file
1638
plugins/platforms/line/adapter.py
Normal file
File diff suppressed because it is too large
Load diff
65
plugins/platforms/line/plugin.yaml
Normal file
65
plugins/platforms/line/plugin.yaml
Normal 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
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
644
tests/gateway/test_line_plugin.py
Normal file
644
tests/gateway/test_line_plugin.py
Normal 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"
|
||||
|
|
@ -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.
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
198
website/docs/user-guide/messaging/line.md
Normal file
198
website/docs/user-guide/messaging/line.md
Normal 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.
|
||||
|
|
@ -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',
|
||||
],
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue