feat(modules): 帮助中心、工单、工作流与多渠道接入
补齐 FAQ、帮助中心、工单、可视化工作流及 Telegram/WhatsApp/Slack 等渠道模块。
This commit is contained in:
1 parent
ba91abc4b2
commit
83a16ed07d
225 files changed
+59337
No files matched your search
@@ -0,0 +1,172 @@
|
||||
"""
|
||||
Copyright 2024-2026 ChatterMate
|
||||
|
||||
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())
|
||||
Reference in new issue
Block a user