Files
replypier/backend/app/channels/line.py
T
WangShen007 20fef2b9f3 chore: settle product naming across app, widget and deployments
One word everywhere: ReplyPier in UI copy, package and image names, the
replypier.site domains (app/api/docs/help), REPLYPIER_* settings and the
--rp-* design tokens.

- public widget API is window.ReplyPier with replypierId / replypierBaseUrl /
  replypierConfig / replypierToken, loader at webclient/replypier.min.js;
  both bundles rebuilt from source
- NOTICE, llms.txt, CONTRIBUTING, HELP_CENTER_INFRA and the demo runtime
  config rewritten; the demo config ships no third-party keys
- migration adds the ReplyPier managed model value to the AI model enum
- local compose project, database, role, volume and Docker network carry the
  same name
2026-10-26 00:57:22 +08:00

173 lines
6.7 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
Copyright 2024-2026 ReplyPier
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
"""
import base64
import hashlib
import hmac
import json
from datetime import datetime, timezone
from typing import ClassVar, List, Optional
import httpx
from app.channels.base import ChannelAdapter, InboundMessage, SendResult
from app.channels.registry import register_adapter
from app.core.security import decrypt_api_key
from app.models.channels import ChannelAccount, ChannelConversation, ChannelType
from app.core.logger import get_logger
from app.core.external_urls import external_base
logger = get_logger(__name__)
LINE_API_BASE = external_base("LINE_API_BASE", "https://api.line.me/v2/bot")
REQUEST_TIMEOUT_SECONDS = 15.0
# https://developers.line.biz/en/reference/messaging-api/#text-message
MAX_MESSAGE_LENGTH = 5000
# Loading-animation duration: 5–60, in multiples of 5 (auto-dismissed on reply)
LOADING_ANIMATION_SECONDS = 20
_http_client: Optional[httpx.AsyncClient] = None
def _get_http_client() -> httpx.AsyncClient:
global _http_client
if _http_client is None or _http_client.is_closed:
_http_client = httpx.AsyncClient(timeout=REQUEST_TIMEOUT_SECONDS)
return _http_client
def credentials(account: ChannelAccount) -> dict:
return json.loads(decrypt_api_key(account.encrypted_credentials))
async def get_bot_info(channel_access_token: str) -> Optional[dict]:
"""Validate a channel access token; returns the bot info or None."""
try:
response = await _get_http_client().get(
f"{LINE_API_BASE}/info",
headers={"Authorization": f"Bearer {channel_access_token}"},
)
return response.json() if response.status_code < 300 else None
except Exception as e:
logger.error(f"LINE bot info failed: {e}")
return None
async def set_webhook_endpoint(channel_access_token: str, url: str) -> bool:
"""Point the LINE channel's webhook at us (best-effort; can also be set in
the LINE Developers console)."""
try:
response = await _get_http_client().put(
f"{LINE_API_BASE}/channel/webhook/endpoint",
headers={"Authorization": f"Bearer {channel_access_token}"},
json={"endpoint": url},
)
return response.status_code < 300
except Exception as e:
logger.error(f"LINE set webhook failed: {e}")
return False
class LineAdapter(ChannelAdapter):
"""LINE Messaging API. Conversations are keyed by the LINE userId; replies
go out as push messages (reply tokens expire too quickly for AI latency)."""
channel_type: ClassVar[str] = ChannelType.LINE.value
async def verify_webhook(self, headers: dict, raw_body: bytes, account: Optional[ChannelAccount]) -> bool:
"""X-Line-Signature: base64(HMAC-SHA256(channel_secret, body))."""
if account is None:
return False
signature = headers.get("x-line-signature", "")
try:
secret = credentials(account).get("channel_secret", "")
except Exception as e:
logger.error(f"LINE credential decrypt failed for {account.id}: {e}")
return False
if not signature or not secret:
return False
expected = base64.b64encode(
hmac.new(secret.encode(), raw_body, hashlib.sha256).digest()
).decode()
return hmac.compare_digest(expected, signature)
def parse_inbound(self, payload: dict) -> List[InboundMessage]:
"""Normalize webhook events; only one-on-one text messages become
InboundMessages."""
messages: List[InboundMessage] = []
for event in payload.get("events", []):
message = event.get("message") or {}
source = event.get("source") or {}
if event.get("type") != "message" or message.get("type") != "text":
continue
user_id = source.get("userId")
if not user_id:
continue
timestamp = None
if event.get("timestamp"):
timestamp = datetime.fromtimestamp(event["timestamp"] / 1000, tz=timezone.utc)
messages.append(InboundMessage(
external_account_id=str(payload.get("destination", "")),
external_conversation_id=str(user_id),
external_user_id=str(user_id),
external_message_id=str(message.get("id", "")),
text=message.get("text", ""),
timestamp=timestamp,
))
return messages
async def send_text(self, account: ChannelAccount, conversation: ChannelConversation, text: str) -> SendResult:
try:
response = await _get_http_client().post(
f"{LINE_API_BASE}/message/push",
headers={"Authorization": f"Bearer {credentials(account)['channel_access_token']}"},
json={
"to": conversation.external_conversation_id,
"messages": [{"type": "text", "text": text}],
},
)
if response.status_code >= 300:
try:
error = response.json().get("message", f"HTTP {response.status_code}")
except Exception:
error = f"HTTP {response.status_code}"
return SendResult(ok=False, error=str(error))
return SendResult(ok=True)
except Exception as e:
logger.error(f"LINE send failed: {e}")
return SendResult(ok=False, error=str(e))
async def send_typing(self, account: ChannelAccount, conversation: ChannelConversation) -> None:
# LINE's loading animation (1:1 chats only); auto-dismisses when the
# reply is sent or after loadingSeconds.
try:
await _get_http_client().post(
f"{LINE_API_BASE}/chat/loading/start",
headers={"Authorization": f"Bearer {credentials(account)['channel_access_token']}"},
json={
"chatId": conversation.external_conversation_id,
"loadingSeconds": LOADING_ANIMATION_SECONDS,
},
)
except Exception as e:
logger.debug(f"LINE loading animation failed (non-critical): {e}")
def format_outbound(self, markdown: str) -> str:
return markdown[:MAX_MESSAGE_LENGTH]
register_adapter(LineAdapter())