refactor(all): pack register() kwargs, table-drive Teams standalone preflight
This commit is contained in:
@@ -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 "
|
||||
|
||||
@@ -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 "
|
||||
|
||||
@@ -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 "
|
||||
|
||||
Reference in New Issue
Block a user