新增了完整的Confluence集成插件,包含以下核心功能: 1. 基础认证与配置管理,支持API Token和OAuth2两种认证方式 2. 评论去重、权限控制与提及解析 3. 知识库检索与页面内容处理 4. 评论收发、编辑删除与流式回复支持 5. 附件与页面标签管理 6. 内容属性存储与AI元数据管理 7. Webhook事件接收与处理 8. 完整的插件配置与状态检查
493 lines
17 KiB
Python
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
|