ForcePilot/backend/package/yuxi/channel/extensions/confluence/gateway.py
Kris f943c31ce4 feat(confluence): 新增Confluence渠道插件完整实现
新增了完整的Confluence集成插件,包含以下核心功能:
1.  基础认证与配置管理,支持API Token和OAuth2两种认证方式
2.  评论去重、权限控制与提及解析
3.  知识库检索与页面内容处理
4.  评论收发、编辑删除与流式回复支持
5.  附件与页面标签管理
6.  内容属性存储与AI元数据管理
7.  Webhook事件接收与处理
8.  完整的插件配置与状态检查
2026-05-21 10:43:42 +08:00

493 lines
17 KiB
Python

import asyncio
import logging
import re
import time
from collections.abc import Callable
from datetime import datetime, UTC
import httpx
from yuxi.channel.extensions.confluence.dedupe import CommentDeduplicator
from yuxi.channel.extensions.confluence.search import CQLBuilder
from yuxi.channel.extensions.confluence.types import (
ConfluenceAccount,
InboundConfluenceComment,
PageContext,
)
logger = logging.getLogger(__name__)
class ConfluenceClient:
def __init__(self, account: ConfluenceAccount):
self._account = account
self._http: httpx.AsyncClient | None = None
self._username = account.email
self._rate_limit_remaining = 65_000
async def __aenter__(self):
auth_header = self._account.auth_header
self._http = httpx.AsyncClient(
base_url=self._account.base_url,
headers={
**auth_header,
"Accept": "application/json",
"Content-Type": "application/json",
},
timeout=httpx.Timeout(30.0),
)
return self
async def __aexit__(self, *args):
if self._http:
await self._http.aclose()
async def _request(self, method: str, path: str, **kwargs) -> dict:
if self._http is None:
raise RuntimeError("Client not initialized")
if self._rate_limit_remaining < 100:
await asyncio.sleep(0.5)
elif self._rate_limit_remaining < 500:
await asyncio.sleep(0.1)
resp = await self._http.request(method, path, **kwargs)
self._update_rate_limit(resp.headers)
if resp.status_code == 429:
retry_after = int(resp.headers.get("Retry-After", "5"))
logger.warning("Confluence rate limited, retry after %ds", retry_after)
await asyncio.sleep(retry_after)
resp = await self._http.request(method, path, **kwargs)
self._update_rate_limit(resp.headers)
resp.raise_for_status()
return resp.json()
def _update_rate_limit(self, headers: dict):
remaining = headers.get("X-RateLimit-Remaining")
if remaining is not None:
self._rate_limit_remaining = int(remaining)
async def cql_search(self, cql: str, limit: int = 50, expand: str | None = None) -> dict:
params = {"cql": cql, "limit": limit}
if expand:
params["expand"] = expand
return await self._request("GET", "/rest/api/search", params=params)
async def create_footer_comment(self, payload: dict) -> dict:
return await self._request("POST", "/wiki/api/v2/footer-comments", json=payload)
async def get_comment_children(self, comment_id: str, limit: int = 25) -> dict:
return await self._request(
"GET",
f"/wiki/api/v2/footer-comments/{comment_id}/children",
params={"limit": limit},
)
async def get_page(self, page_id: str, body_format: str = "atlas_doc_format") -> dict:
return await self._request(
"GET",
f"/wiki/api/v2/pages/{page_id}",
params={"body-format": body_format},
)
async def update_page(self, page_id: str, payload: dict) -> dict:
return await self._request("PUT", f"/wiki/api/v2/pages/{page_id}", json=payload)
async def get_space(self, space_id: str) -> dict:
return await self._request("GET", f"/wiki/api/v2/spaces/{space_id}")
async def create_page(self, payload: dict) -> dict:
return await self._request("POST", "/wiki/api/v2/pages", json=payload)
async def update_footer_comment(self, comment_id: str, adf_body: str) -> dict:
return await self._request(
"PUT",
f"/wiki/api/v2/footer-comments/{comment_id}",
json={
"body": {
"representation": "atlas_doc_format",
"value": adf_body,
},
},
)
async def delete_footer_comment(self, comment_id: str) -> None:
await self._request("DELETE", f"/wiki/api/v2/footer-comments/{comment_id}")
async def get_footer_comments(
self,
page_id: str,
cursor: str | None = None,
limit: int = 25,
sort: str = "created-date",
) -> dict:
params = {"limit": limit, "sort": sort}
if cursor:
params["cursor"] = cursor
return await self._request(
"GET",
f"/wiki/api/v2/pages/{page_id}/footer-comments",
params=params,
)
async def get_comment(self, comment_id: str) -> dict:
return await self._request("GET", f"/wiki/api/v2/footer-comments/{comment_id}")
async def delete_page(self, page_id: str, purge: bool = False) -> None:
params = {"purge": str(purge).lower()}
await self._request("DELETE", f"/wiki/api/v2/pages/{page_id}", params=params)
async def get_space_pages(
self,
space_id: str,
cursor: str | None = None,
limit: int = 25,
depth: str = "all",
) -> dict:
params = {"limit": limit, "depth": depth}
if cursor:
params["cursor"] = cursor
return await self._request(
"GET",
f"/wiki/api/v2/spaces/{space_id}/pages",
params=params,
)
async def get_spaces(self, limit: int = 10, cursor: str | None = None) -> dict:
params = {"limit": limit}
if cursor:
params["cursor"] = cursor
return await self._request("GET", "/wiki/api/v2/spaces", params=params)
async def get_users(
self, query: str | None = None, limit: int = 25,
) -> dict:
params = {"limit": limit}
if query:
params["query"] = query
return await self._request("GET", "/wiki/api/v2/users", params=params)
async def create_space(self, key: str, name: str, description: str = "") -> dict:
payload = {
"key": key,
"name": name,
}
if description:
payload["description"] = {"plain": {"value": description}}
return await self._request("POST", "/wiki/api/v2/spaces", json=payload)
async def delete_space(self, space_id: str) -> None:
await self._request("DELETE", f"/wiki/api/v2/spaces/{space_id}")
async def get_page_children(self, page_id: str, limit: int = 25) -> dict:
return await self._request(
"GET",
f"/wiki/api/v2/pages/{page_id}/children",
params={"limit": limit},
)
async def get_page_ancestors(self, page_id: str) -> dict:
return await self._request("GET", f"/wiki/api/v2/pages/{page_id}/ancestors")
async def get_page_versions(self, page_id: str, limit: int = 25) -> dict:
return await self._request(
"GET",
f"/wiki/api/v2/pages/{page_id}/versions",
params={"limit": limit},
)
async def list_all_footer_comments(
self,
cursor: str | None = None,
limit: int = 25,
sort: str = "created-date",
) -> dict:
params = {"limit": limit, "sort": sort}
if cursor:
params["cursor"] = cursor
return await self._request("GET", "/wiki/api/v2/footer-comments", params=params)
async def update_page_title(self, page_id: str, title: str) -> dict:
return await self._request(
"PUT",
f"/wiki/api/v2/pages/{page_id}/title",
json={"title": title},
)
async def update_space(self, space_id: str, name: str, description: str = "") -> dict:
payload: dict = {"name": name}
if description:
payload["description"] = {"plain": {"value": description}}
return await self._request(
"PUT", f"/wiki/api/v2/spaces/{space_id}", json=payload,
)
async def get_blogpost(self, blogpost_id: str, body_format: str = "atlas_doc_format") -> dict:
return await self._request(
"GET",
f"/wiki/api/v2/blogposts/{blogpost_id}",
params={"body-format": body_format},
)
async def create_blogpost(self, payload: dict) -> dict:
return await self._request("POST", "/wiki/api/v2/blogposts", json=payload)
async def update_blogpost(self, blogpost_id: str, payload: dict) -> dict:
return await self._request("PUT", f"/wiki/api/v2/blogposts/{blogpost_id}", json=payload)
async def delete_blogpost(self, blogpost_id: str) -> None:
await self._request("DELETE", f"/wiki/api/v2/blogposts/{blogpost_id}")
async def get_tasks(self, page_id: str, limit: int = 25) -> dict:
return await self._request(
"GET",
f"/wiki/api/v2/pages/{page_id}/tasks",
params={"limit": limit},
)
async def get_task(self, task_id: str) -> dict:
return await self._request("GET", f"/wiki/api/v2/tasks/{task_id}")
async def update_task(self, task_id: str, state: str = "COMPLETE") -> dict:
return await self._request(
"PUT",
f"/wiki/api/v2/tasks/{task_id}",
json={"state": state},
)
async def _paginated_request(
self,
method: str,
path: str,
params: dict | None = None,
max_results: int = 250,
) -> list[dict]:
cursor = None
all_results: list[dict] = []
params = params or {}
params.setdefault("limit", 25)
while len(all_results) < max_results:
if cursor:
params["cursor"] = cursor
if self._http is None:
raise RuntimeError("Client not initialized")
resp = await self._http.request(method, path, params=params)
self._update_rate_limit(resp.headers)
resp.raise_for_status()
page = resp.json()
all_results.extend(page.get("results", []))
links = resp.headers.get("Link", "")
cursor = self._extract_next_cursor(links)
if not cursor:
break
return all_results[:max_results]
@staticmethod
def _extract_next_cursor(link_header: str) -> str | None:
match = re.search(r'<[^>]*cursor=([^>&]+)[^>]*>\s*;\s*rel="next"', link_header)
if match:
return match.group(1)
return None
class ConfluenceGateway:
MAX_RETRY_BACKOFF = 300.0
MAX_SEEN_COMMENTS = 50_000
def __init__(self):
self._active_clients: dict[str, ConfluenceClient] = {}
self._poll_tasks: dict[str, asyncio.Task] = {}
self._last_poll_time: dict[str, float] = {}
self._deduplicator = CommentDeduplicator(max_size=50_000, ttl_seconds=600)
self._cancel_events: dict[str, asyncio.Event] = {}
async def start(
self,
account_id: str,
account: ConfluenceAccount,
on_comment: Callable[[InboundConfluenceComment], None],
authorize_sender: Callable[[str, dict], bool],
) -> ConfluenceClient:
client = ConfluenceClient(account)
await client.__aenter__()
self._active_clients[account_id] = client
if account.comment_poll_enabled:
cancel = asyncio.Event()
self._cancel_events[account_id] = cancel
task = asyncio.create_task(
self._poll_comments_loop(account_id, account, client, on_comment, authorize_sender, cancel)
)
self._poll_tasks[account_id] = task
return client
async def stop(self, account_id: str):
cancel = self._cancel_events.pop(account_id, None)
if cancel:
cancel.set()
task = self._poll_tasks.pop(account_id, None)
if task:
task.cancel()
try:
await task
except asyncio.CancelledError:
pass
client = self._active_clients.pop(account_id, None)
if client:
await client.__aexit__(None, None, None)
async def stop_all(self):
for account_id in list(self._active_clients.keys()):
await self.stop(account_id)
def get_client(self, account_id: str = "default") -> ConfluenceClient | None:
return self._active_clients.get(account_id)
async def _poll_comments_loop(
self,
account_id: str,
account: ConfluenceAccount,
client: ConfluenceClient,
on_comment: Callable,
authorize_sender: Callable,
cancel: asyncio.Event,
):
error_count = 0
last_poll = self._last_poll_time.get(account_id, time.time() - 3600)
seen_in_session: set[str] = set()
while not cancel.is_set():
try:
since_iso = datetime.fromtimestamp(last_poll, tz=UTC).strftime("%Y-%m-%dT%H:%M:%S.000Z")
cql = CQLBuilder.comment_poll(since_iso, account.space_keys)
results = await client.cql_search(cql, limit=50, expand="body.view,history.lastUpdated,container")
new_max_time = last_poll
for result in results.get("results", []):
content = result.get("content", {})
comment_id = content.get("id", "")
if not comment_id:
continue
if comment_id in seen_in_session:
continue
if self._deduplicator.is_duplicate(comment_id):
continue
seen_in_session.add(comment_id)
self._deduplicator.mark(comment_id)
author_info = content.get("history", {}).get("createdBy", {})
author_id = author_info.get("accountId", "")
if self._is_self_comment(client, author_info):
continue
if not authorize_sender(author_id, result):
continue
page_ctx = self._extract_page_context(result)
if page_ctx is None:
continue
comment_text = self._extract_comment_text(result)
inbound = InboundConfluenceComment(
comment_id=comment_id,
author_id=author_id,
author_name=author_info.get("displayName", ""),
body_text=comment_text,
container_id=page_ctx.page_id,
parent_comment_id=self._extract_parent_comment_id(result),
page_context=page_ctx,
created_at=self._parse_datetime(content.get("history", {}).get("createdDate")),
)
on_comment(inbound)
updated = content.get("history", {}).get("lastUpdated", {}).get("when", "")
updated_ts = self._parse_timestamp(updated)
if updated_ts and updated_ts > new_max_time:
new_max_time = updated_ts
last_poll = new_max_time
self._last_poll_time[account_id] = new_max_time
error_count = 0
except asyncio.CancelledError:
break
except Exception as e:
error_count += 1
backoff = min(2.0 * (2**error_count), self.MAX_RETRY_BACKOFF)
logger.error(
"Confluence poll error (attempt %d, retry in %.1fs): %s",
error_count,
backoff,
e,
)
try:
await asyncio.wait_for(cancel.wait(), timeout=account.poll_interval)
break
except TimeoutError:
pass
def _is_self_comment(self, client: ConfluenceClient, author: dict) -> bool:
author_email = author.get("email", "")
return author_email and author_email.lower() == client._username.lower()
def _extract_page_context(self, result: dict) -> PageContext | None:
container = result.get("resultParentContainer")
if not container:
return None
return PageContext(
page_id=container.get("id", ""),
page_title=container.get("title", ""),
space_key=container.get("space", {}).get("key", ""),
)
def _extract_comment_text(self, result: dict) -> str:
body = result.get("content", {}).get("body", {})
view_value = body.get("view", {}).get("value", "")
return self._strip_html(view_value)
def _extract_parent_comment_id(self, result: dict) -> str | None:
ancestors = result.get("content", {}).get("ancestors", [])
for ancestor in reversed(ancestors):
if ancestor.get("type") == "comment":
return ancestor.get("id")
return None
@staticmethod
def _strip_html(html: str) -> str:
clean = re.sub(r"<[^>]+>", "", html)
clean = re.sub(r"&[a-z]+;", " ", clean)
return re.sub(r"\s+", " ", clean).strip()
@staticmethod
def _parse_timestamp(iso_str: str | None) -> float:
if not iso_str:
return 0.0
try:
dt = datetime.fromisoformat(iso_str.replace("Z", "+00:00"))
return dt.timestamp()
except (ValueError, TypeError):
return 0.0
@staticmethod
def _parse_datetime(iso_str: str | None) -> datetime | None:
if not iso_str:
return None
try:
return datetime.fromisoformat(iso_str.replace("Z", "+00:00"))
except (ValueError, TypeError):
return None