172 lines
6.7 KiB
Python
172 lines
6.7 KiB
Python
"""
|
||||
|
|
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())
|