实现了完整的Flock渠道接入能力,包含消息收发、Webhook事件监听、账号配置、安全校验、媒体文件处理等功能,支持私聊和群组聊天,适配ForcePilot插件规范。
168 lines
5.2 KiB
Python
168 lines
5.2 KiB
Python
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import logging
|
|
import random
|
|
import re
|
|
|
|
import httpx
|
|
|
|
from .constants import (
|
|
API_TIMEOUT,
|
|
BASE_URL,
|
|
BOT_TOKEN_PATTERN,
|
|
FLOCK_GROUP_ID_PATTERN,
|
|
FLOCK_USER_ID_PATTERN,
|
|
MAX_RETRIES,
|
|
RATE_LIMIT_RETRY_AFTER_DEFAULT,
|
|
RETRY_BASE_DELAY,
|
|
RETRY_MAX_DELAY,
|
|
)
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
_bot_token_re = re.compile(BOT_TOKEN_PATTERN)
|
|
_user_id_re = re.compile(FLOCK_USER_ID_PATTERN)
|
|
_group_id_re = re.compile(FLOCK_GROUP_ID_PATTERN)
|
|
|
|
|
|
class FlockAPIError(Exception):
|
|
def __init__(self, error: str, description: str = "", retryable: bool = False):
|
|
super().__init__(f"Flock API Error: {error} - {description}")
|
|
self.error = error
|
|
self.description = description
|
|
self.retryable = retryable
|
|
|
|
|
|
def create_http_client(timeout: float = API_TIMEOUT) -> httpx.AsyncClient:
|
|
return httpx.AsyncClient(timeout=timeout)
|
|
|
|
|
|
def build_auth_headers(bot_token: str) -> dict[str, str]:
|
|
return {
|
|
"Authorization": f"Bearer {bot_token}",
|
|
"Content-Type": "application/json",
|
|
}
|
|
|
|
|
|
def validate_bot_token(token: str) -> bool:
|
|
return bool(_bot_token_re.match(token))
|
|
|
|
|
|
def validate_flock_user_id(user_id: str) -> bool:
|
|
return bool(_user_id_re.match(user_id))
|
|
|
|
|
|
def validate_flock_group_id(group_id: str) -> bool:
|
|
return bool(_group_id_re.match(group_id))
|
|
|
|
|
|
def strip_flock_prefix(identifier: str) -> str:
|
|
for prefix in ("u:", "g:", "t:", "b:"):
|
|
if identifier.startswith(prefix):
|
|
return identifier[len(prefix) :]
|
|
return identifier
|
|
|
|
|
|
async def flock_api_call(
|
|
client: httpx.AsyncClient,
|
|
endpoint: str,
|
|
token: str,
|
|
payload: dict,
|
|
max_retries: int = MAX_RETRIES,
|
|
) -> dict:
|
|
url = f"{BASE_URL}{endpoint}"
|
|
headers = build_auth_headers(token)
|
|
|
|
for attempt in range(max_retries + 1):
|
|
try:
|
|
resp = await client.post(url, json=payload, headers=headers)
|
|
|
|
if resp.status_code == 200:
|
|
return resp.json()
|
|
|
|
if resp.status_code == 429:
|
|
retry_after = int(resp.headers.get("Retry-After", RATE_LIMIT_RETRY_AFTER_DEFAULT))
|
|
if attempt < max_retries:
|
|
logger.warning(
|
|
"Flock API rate limited (429) for %s, retrying after %ds (attempt %d/%d)",
|
|
endpoint,
|
|
retry_after,
|
|
attempt + 1,
|
|
max_retries,
|
|
)
|
|
await asyncio.sleep(retry_after)
|
|
continue
|
|
raise FlockAPIError("RateLimitExceeded", "Rate limit exceeded", retryable=True)
|
|
|
|
if resp.status_code >= 500:
|
|
if attempt < max_retries:
|
|
wait = RETRY_BASE_DELAY * (2**attempt) + random.uniform(0, 1)
|
|
wait = min(wait, RETRY_MAX_DELAY)
|
|
logger.warning(
|
|
"Flock API server error (%d) for %s, retrying in %.1fs (attempt %d/%d)",
|
|
resp.status_code,
|
|
endpoint,
|
|
wait,
|
|
attempt + 1,
|
|
max_retries,
|
|
)
|
|
await asyncio.sleep(wait)
|
|
continue
|
|
error_data = _parse_error_body(resp)
|
|
raise FlockAPIError(
|
|
error_data.get("error", "InternalError"),
|
|
error_data.get("errorDescription", str(resp.status_code)),
|
|
retryable=True,
|
|
)
|
|
|
|
error_data = _parse_error_body(resp)
|
|
raise FlockAPIError(
|
|
error_data.get("error", f"HTTP {resp.status_code}"),
|
|
error_data.get("errorDescription", ""),
|
|
retryable=False,
|
|
)
|
|
|
|
except httpx.TimeoutException:
|
|
if attempt < max_retries:
|
|
wait = RETRY_BASE_DELAY * (2**attempt)
|
|
logger.warning(
|
|
"Flock API timeout for %s, retrying in %.1fs (attempt %d/%d)",
|
|
endpoint,
|
|
wait,
|
|
attempt + 1,
|
|
max_retries,
|
|
)
|
|
await asyncio.sleep(wait)
|
|
continue
|
|
raise FlockAPIError("Timeout", f"Request to {endpoint} timed out", retryable=True)
|
|
|
|
raise FlockAPIError("MaxRetriesExceeded", f"Failed after {max_retries} retries", retryable=True)
|
|
|
|
|
|
def _parse_error_body(resp: httpx.Response) -> dict:
|
|
try:
|
|
return resp.json()
|
|
except Exception:
|
|
return {"error": f"HTTP {resp.status_code}", "errorDescription": resp.text[:200]}
|
|
|
|
|
|
def classify_api_error(resp: httpx.Response) -> tuple[str, str, bool]:
|
|
error_code, error_description = "UnknownError", ""
|
|
retryable = False
|
|
try:
|
|
body = resp.json()
|
|
error_code = body.get("error", f"HTTP {resp.status_code}")
|
|
error_description = body.get("errorDescription", "")
|
|
except Exception:
|
|
error_code = f"HTTP {resp.status_code}"
|
|
|
|
if resp.status_code == 429 or resp.status_code >= 500:
|
|
retryable = True
|
|
|
|
return error_code, error_description, retryable
|
|
|
|
|
|
def is_retryable_http_status(status_code: int) -> bool:
|
|
return status_code == 429 or status_code >= 500
|