refactor(all): pack register() kwargs, table-drive Teams standalone preflight

This commit is contained in:
Teknium
2026-09-02 23:07:17 -07:00
parent 822dfb0389
commit f2a9044a19
3 changed files with 38 additions and 90 deletions
+10 -32
View File
@@ -47,7 +47,6 @@ LINE_PUSH_URL = "https://api.line.me/v2/bot/message/push"
LINE_LOADING_URL = "https://api.line.me/v2/bot/chat/loading/start"
LINE_CONTENT_URL_FMT = "https://api-data.line.me/v2/bot/message/{message_id}/content"
LINE_BOT_INFO_URL = "https://api.line.me/v2/bot/info"
LINE_PER_BUBBLE_CHARS = 5000 # LINE hard limit
LINE_SAFE_BUBBLE_CHARS = 4500 # conservative chunking limit
LINE_MAX_MESSAGES_PER_CALL = 5
@@ -56,38 +55,29 @@ WEBHOOK_BODY_MAX_BYTES = 1_048_576 # 1 MiB — webhooks are tiny JSON
DEFAULT_WEBHOOK_PORT = 8646
DEFAULT_WEBHOOK_PATH = "/line/webhook"
DEFAULT_MEDIA_PATH_PREFIX = "/line/media"
# ``None`` → asyncio binds BOTH address families (mirrors gateway/platforms/webhook.py).
# "0.0.0.0" is unreachable on IPv6-only networks (Fly.io 6PN → 502s); "::" breaks IPv4
# loopback probes on IPV6_V6ONLY=1 hosts. Pin via ``LINE_HOST`` / ``extra.host``.
DEFAULT_HOST = None
# Bind hosts LINE's servers could never fetch media from → public URL required.
_WILDCARD_HOSTS = frozenset({"0.0.0.0", "::", ""})
_WILDCARD_HOSTS = frozenset({"0.0.0.0", "::", ""}) # LINE can't fetch media from these → public URL required
DEFAULT_SLOW_RESPONSE_THRESHOLD = 45.0 # seconds; 0 disables the postback button
DEFAULT_PENDING_REPLY_TEXT = "🤔 Still thinking. Tap below to fetch the answer when it's ready."
DEFAULT_BUTTON_LABEL = "Get answer"
DEFAULT_DELIVERED_TEXT = "Already replied ✅"
DEFAULT_INTERRUPTED_TEXT = "Run was interrupted before completion."
MEDIA_TOKEN_TTL_SECONDS = 1800 # 30 minutes; LINE caches the URL aggressively
LINE_IMAGE_MAX_BYTES = 10 * 1024 * 1024 # 10 MB per LINE docs
LINE_AV_MAX_BYTES = 200 * 1024 * 1024 # 200 MB for voice/video
# LINE message type → normalized MessageType. LINE audio is recorded voice clips →
# VOICE (STT path), like Telegram/WhatsApp. Unknown types fall back to TEXT.
_LINE_MESSAGE_TYPES = {
"text": MessageType.TEXT, "image": MessageType.PHOTO, "video": MessageType.VIDEO, "audio": MessageType.VOICE,
"file": MessageType.DOCUMENT, "location": MessageType.LOCATION, "sticker": MessageType.STICKER}
# 1×1 transparent PNG: fallback video preview (LINE requires ``previewImageUrl``).
_FALLBACK_PNG_PREVIEW = bytes.fromhex(
"89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c4"
"890000000d49444154789c63000100000005000100377a7ff20000000049454e"
"44ae426082")
# Markdown LINE can't render, applied in order (code blocks first so their content survives).
_MD_STRIP_RULES: Tuple[Tuple[re.Pattern, Any], ...] = (
(re.compile(r"```[a-zA-Z0-9_+-]*\n?(.*?)```", re.DOTALL), lambda m: m.group(1).rstrip("\n")),
@@ -111,10 +101,8 @@ def strip_markdown_preserving_urls(text: str) -> str:
def split_for_line(text: str, max_chars: int = LINE_SAFE_BUBBLE_CHARS) -> List[str]:
"""Split into ≤5 LINE bubbles at paragraph/line/word breaks; overflow is ellipsised."""
if not text:
return []
if len(text) <= max_chars:
return [text]
if not text or len(text) <= max_chars:
return [text] if text else []
chunks: List[str] = []
remaining = text
while remaining and len(chunks) < LINE_MAX_MESSAGES_PER_CALL:
@@ -284,8 +272,8 @@ class _LineClient:
async def fetch_content(self, message_id: str) -> bytes:
"""Download an inbound media message's binary content."""
url = LINE_CONTENT_URL_FMT.format(message_id=message_id)
async with self._session(30.0) as session:
url = LINE_CONTENT_URL_FMT.format(message_id=message_id)
async with session.get(url, headers={"Authorization": f"Bearer {self._token}"}) as resp:
if resp.status >= 400:
raise RuntimeError(f"LINE content {resp.status}")
@@ -972,24 +960,14 @@ def interactive_setup() -> None:
def register(ctx) -> None:
"""Plugin entry point — called by the Hermes plugin system at startup."""
ctx.register_platform(
name="line",
label="LINE",
adapter_factory=lambda cfg: LineAdapter(cfg),
check_fn=check_requirements,
validate_config=validate_config,
is_connected=is_connected,
required_env=["LINE_CHANNEL_ACCESS_TOKEN", "LINE_CHANNEL_SECRET"],
install_hint="pip install aiohttp",
setup_fn=interactive_setup,
env_enablement_fn=_env_enablement,
cron_deliver_env_var="LINE_HOME_CHANNEL",
standalone_sender_fn=_standalone_send,
allowed_users_env="LINE_ALLOWED_USERS",
name="line", label="LINE", adapter_factory=lambda cfg: LineAdapter(cfg), check_fn=check_requirements,
validate_config=validate_config, is_connected=is_connected,
required_env=["LINE_CHANNEL_ACCESS_TOKEN", "LINE_CHANNEL_SECRET"], install_hint="pip install aiohttp",
setup_fn=interactive_setup, env_enablement_fn=_env_enablement, cron_deliver_env_var="LINE_HOME_CHANNEL",
standalone_sender_fn=_standalone_send, allowed_users_env="LINE_ALLOWED_USERS",
allow_all_env="LINE_ALLOW_ALL_USERS",
max_message_length=LINE_SAFE_BUBBLE_CHARS, # per-bubble cap is 5000; smart-chunker uses 4500
emoji="💚",
pii_safe=False,
allow_update_command=True,
emoji="💚", pii_safe=False, allow_update_command=True,
platform_hint=(
"You are chatting via LINE Messaging API. LINE does NOT render "
"Markdown — text bubbles show ** and # literally. Bare URLs are "
+7 -20
View File
@@ -34,10 +34,7 @@ WS_RETRY_DELAY_INITIAL = 2.0
WS_RETRY_DELAY_MAX = 60.0
HEALTH_CHECK_INTERVAL = 30.0
HEALTH_CHECK_STALE_THRESHOLD = 300.0
# Correlation ID prefix for requests we send so we can ignore our own echoes.
_CORR_PREFIX = "hermes-"
_CORR_PREFIX = "hermes-" # marks requests we sent so our own echoes can be ignored
_IMAGE_EXTS = {".jpg", ".jpeg", ".png", ".gif", ".webp"}
_AUDIO_EXTS = {".mp3", ".wav", ".ogg", ".m4a", ".aac", ".opus"}
_VOICE_TAG_EXTS = {".ogg", ".mp3", ".wav", ".m4a", ".opus"} # MEDIA: tags sent as voice notes
@@ -705,24 +702,14 @@ def interactive_setup() -> None:
def register(ctx) -> None:
"""Plugin entry point — called by the Hermes plugin system at startup."""
ctx.register_platform(
name="simplex",
label="SimpleX Chat",
adapter_factory=lambda cfg: SimplexAdapter(cfg),
check_fn=check_requirements,
validate_config=validate_config,
is_connected=is_connected,
name="simplex", label="SimpleX Chat", adapter_factory=lambda cfg: SimplexAdapter(cfg),
check_fn=check_requirements, validate_config=validate_config, is_connected=is_connected,
required_env=["SIMPLEX_WS_URL"],
install_hint=("pip install websockets # SimpleX adapter requires the websockets package"),
setup_fn=interactive_setup,
env_enablement_fn=_env_enablement,
cron_deliver_env_var="SIMPLEX_HOME_CHANNEL",
standalone_sender_fn=_standalone_send,
allowed_users_env="SIMPLEX_ALLOWED_USERS",
allow_all_env="SIMPLEX_ALLOW_ALL_USERS",
max_message_length=MAX_MESSAGE_LENGTH,
emoji="🔒",
# SimpleX uses opaque contact IDs only — nothing to redact.
pii_safe=True,
setup_fn=interactive_setup, env_enablement_fn=_env_enablement, cron_deliver_env_var="SIMPLEX_HOME_CHANNEL",
standalone_sender_fn=_standalone_send, allowed_users_env="SIMPLEX_ALLOWED_USERS",
allow_all_env="SIMPLEX_ALLOW_ALL_USERS", max_message_length=MAX_MESSAGE_LENGTH, emoji="🔒",
pii_safe=True, # SimpleX uses opaque contact IDs only — nothing to redact
allow_update_command=True,
platform_hint=(
"You are chatting via SimpleX Chat, a private decentralised "
+21 -38
View File
@@ -42,7 +42,7 @@ def _probe_teams_sdk_available() -> bool:
TEAMS_SDK_AVAILABLE = _probe_teams_sdk_available()
# SDK symbols stay None until check_teams_requirements() binds them.
# SDK symbols stay None until check_teams_requirements() binds them (via _SDK_IMPORTS below).
ClientOptions = App = ActivityContext = MessageActivity = ConversationReference = None # type: ignore[assignment,misc]
TypingActivityInput = AdaptiveCardInvokeActivity = AdaptiveCardActionCardResponse = None # type: ignore[assignment,misc]
AdaptiveCardActionMessageResponse = AdaptiveCardInvokeResponse = InvokeResponse = None # type: ignore[assignment,misc]
@@ -195,22 +195,20 @@ async def _standalone_send(
return {"error": "Teams standalone send: TEAMS_CLIENT_ID, TEAMS_CLIENT_SECRET, and TEAMS_TENANT_ID are all required"}
raw_service_url = os.getenv("TEAMS_SERVICE_URL") or extra.get("service_url", "") or _DEFAULT_TEAMS_SERVICE_URL
service_url = _validate_teams_service_url(raw_service_url)
if service_url is None:
return {"error": f"Teams standalone send: TEAMS_SERVICE_URL host is not on the Bot Framework allowlist; "
f"expected one of {sorted(_ALLOWED_TEAMS_SERVICE_HOSTS)}"}
if not chat_id:
return {"error": "Teams standalone send: chat_id (conversation ID) is required"}
if not _TEAMS_CONV_ID_RE.match(chat_id):
return {"error": "Teams standalone send: chat_id contains characters outside the Bot Framework conversation ID set"}
if not _TEAMS_CONV_ID_RE.match(tenant_id):
return {"error": "Teams standalone send: TEAMS_TENANT_ID contains characters outside the expected set"}
for failed, error in (
(service_url is None, f"TEAMS_SERVICE_URL host is not on the Bot Framework allowlist; "
f"expected one of {sorted(_ALLOWED_TEAMS_SERVICE_HOSTS)}"),
(not chat_id, "chat_id (conversation ID) is required"),
(not _TEAMS_CONV_ID_RE.match(chat_id or ""), "chat_id contains characters outside the Bot Framework conversation ID set"),
(not _TEAMS_CONV_ID_RE.match(tenant_id), "TEAMS_TENANT_ID contains characters outside the expected set"),
(not AIOHTTP_AVAILABLE, "aiohttp not installed"),
):
if failed:
return {"error": f"Teams standalone send: {error}"}
token_url, token_form = _bf_token_request(tenant_id, client_id, client_secret)
activities_url = f"{service_url}v3/conversations/{chat_id}/activities"
if not AIOHTTP_AVAILABLE:
return {"error": "Teams standalone send: aiohttp not installed"}
try:
import aiohttp as _aiohttp
# Per-request timeouts so a slow STS endpoint cannot starve the activity POST.
per_request_timeout = _aiohttp.ClientTimeout(total=15.0)
async with _aiohttp.ClientSession(trust_env=gateway_trust_env()) as session:
@@ -733,17 +731,11 @@ _SETUP_CREDENTIALS = (
("Client ID", "TEAMS_CLIENT_ID", {}),
("Client secret", "TEAMS_CLIENT_SECRET", {"password": True}),
("Tenant ID", "TEAMS_TENANT_ID", {}))
_SETUP_INTRO = (
"You'll need the Teams CLI. If you haven't already:",
" npm install -g @microsoft/teams.cli@preview",
" teams login",
"",
"Then expose port 3978 publicly (devtunnel / ngrok / cloudflared),",
"and create your bot:",
' teams app create --name "Hermes" --endpoint "https://<tunnel>/api/messages"',
"",
"The CLI will print CLIENT_ID, CLIENT_SECRET, and TENANT_ID. Paste them below.",
"")
_SETUP_INTRO = ( # "" → blank line
"You'll need the Teams CLI. If you haven't already:", " npm install -g @microsoft/teams.cli@preview",
" teams login", "", "Then expose port 3978 publicly (devtunnel / ngrok / cloudflared),", "and create your bot:",
' teams app create --name "Hermes" --endpoint "https://<tunnel>/api/messages"', "",
"The CLI will print CLIENT_ID, CLIENT_SECRET, and TENANT_ID. Paste them below.", "")
def interactive_setup() -> None:
@@ -755,7 +747,6 @@ def interactive_setup() -> None:
print_info(f"Teams: already configured (app ID: {existing_id})")
if not prompt_yes_no("Reconfigure Teams?", False):
return
for line in _SETUP_INTRO:
print_info(line) if line else print()
for label, env_key, prompt_kwargs in _SETUP_CREDENTIALS:
@@ -764,7 +755,6 @@ def interactive_setup() -> None:
print_warning(f"{label} is required — skipping Teams setup")
return
save_env_value(env_key, value.strip())
print()
print_info("To find your AAD object ID for the allowlist: teams status --verbose")
if prompt_yes_no("Restrict access to specific users? (recommended)", True):
@@ -777,7 +767,6 @@ def interactive_setup() -> None:
else:
save_env_value("TEAMS_ALLOW_ALL_USERS", "true")
print_warning("⚠️ Open access — anyone who can message the bot can command it.")
print()
print_success("Teams configuration saved to ~/.hermes/.env")
print_info("Install the app in Teams: teams app install --id <teamsAppId>")
@@ -800,24 +789,18 @@ def _install_hint() -> str:
def register(ctx) -> None:
"""Plugin entry point — called by the Hermes plugin system."""
ctx.register_platform(
name="teams",
label="Microsoft Teams",
adapter_factory=lambda cfg: TeamsAdapter(cfg),
name="teams", label="Microsoft Teams", adapter_factory=lambda cfg: TeamsAdapter(cfg),
check_fn=check_requirements, # PASSIVE probe — never installs
ensure_deps_fn=check_teams_requirements, # ACTIVE lazy-installer, run by create_adapter()
validate_config=validate_config,
is_connected=is_connected,
validate_config=validate_config, is_connected=is_connected,
required_env=["TEAMS_CLIENT_ID", "TEAMS_CLIENT_SECRET", "TEAMS_TENANT_ID"],
install_hint=_install_hint(),
setup_fn=interactive_setup,
install_hint=_install_hint(), setup_fn=interactive_setup,
env_enablement_fn=_env_enablement, # env-only setups show up in gateway status
cron_deliver_env_var="TEAMS_HOME_CHANNEL", # deliver=teams cron home-channel routing
standalone_sender_fn=_standalone_send, # out-of-process cron delivery via Bot Framework REST
allowed_users_env="TEAMS_ALLOWED_USERS",
allow_all_env="TEAMS_ALLOW_ALL_USERS",
allowed_users_env="TEAMS_ALLOWED_USERS", allow_all_env="TEAMS_ALLOW_ALL_USERS",
max_message_length=28000, # Teams supports up to ~28 KB per message
emoji="💼",
allow_update_command=True,
emoji="💼", allow_update_command=True,
platform_hint=(
"You are chatting via Microsoft Teams. Teams renders a subset of "
"markdown — bold (**text**), italic (*text*), and inline code "