ForcePilot/backend/package/yuxi/channel/extensions/confluence/outbound.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

280 lines
9.0 KiB
Python

import asyncio
import logging
from yuxi.channel.extensions.confluence.format import ADFBuilder
from yuxi.channel.extensions.confluence.gateway import ConfluenceGateway
from yuxi.channel.extensions.confluence.search import CQLBuilder
from yuxi.channel.extensions.confluence.types import OutboundResult
logger = logging.getLogger(__name__)
MAX_ADF_BLOCK_CHARS = 2000
MAX_RETRY = 3
RETRY_BASE_DELAY = 1.0
class ConfluenceOutbound:
delivery_mode = "comment"
def __init__(self, gateway: ConfluenceGateway):
self._gateway = gateway
async def reply_to_comment(
self,
page_id: str,
parent_comment_id: str,
text: str,
account_id: str = "default",
) -> OutboundResult:
client = self._gateway.get_client(account_id)
if client is None:
return OutboundResult(success=False, error="Client not found")
adf_body = ADFBuilder.from_markdown(text)
payload = {
"body": {
"representation": "atlas_doc_format",
"value": adf_body,
},
"containerId": page_id,
"parentCommentId": parent_comment_id,
}
return await self._send_with_retry(client, "POST", "/wiki/api/v2/footer-comments", payload)
async def create_comment(
self,
page_id: str,
text: str,
account_id: str = "default",
) -> OutboundResult:
client = self._gateway.get_client(account_id)
if client is None:
return OutboundResult(success=False, error="Client not found")
adf_body = ADFBuilder.from_markdown(text)
payload = {
"body": {
"representation": "atlas_doc_format",
"value": adf_body,
},
"containerId": page_id,
}
return await self._send_with_retry(client, "POST", "/wiki/api/v2/footer-comments", payload)
async def edit_comment(
self,
comment_id: str,
text: str,
account_id: str = "default",
) -> OutboundResult:
client = self._gateway.get_client(account_id)
if client is None:
return OutboundResult(success=False, error="Client not found")
adf_body = ADFBuilder.from_markdown(text)
return await self._send_with_retry(
client, "PUT", f"/wiki/api/v2/footer-comments/{comment_id}",
{"body": {"representation": "atlas_doc_format", "value": adf_body}},
)
async def delete_comment(
self,
comment_id: str,
account_id: str = "default",
) -> OutboundResult:
client = self._gateway.get_client(account_id)
if client is None:
return OutboundResult(success=False, error="Client not found")
try:
await client.delete_footer_comment(comment_id)
return OutboundResult(success=True, comment_id=comment_id)
except Exception as e:
return OutboundResult(success=False, error=str(e))
async def get_comment_thread(
self,
comment_id: str,
account_id: str = "default",
max_depth: int = 3,
) -> list[dict]:
client = self._gateway.get_client(account_id)
if client is None:
return []
results = []
children = await client.get_comment_children(comment_id, limit=25)
for child in children.get("results", []):
results.append(child)
if max_depth > 1:
deeper = await self.get_comment_thread(
child["id"], account_id, max_depth - 1,
)
results.extend(deeper)
return results
async def send_text(
self,
target_id: str,
content: str,
*,
reply_to_id: str | None = None,
thread_id: str | None = None,
account_id: str = "default",
) -> OutboundResult:
page_id = target_id
parent_id = reply_to_id or thread_id
if parent_id:
return await self.reply_to_comment(page_id, parent_id, content, account_id)
return await self.create_comment(page_id, content, account_id)
async def read_page(
self, page_id: str, account_id: str = "default"
) -> dict | None:
client = self._gateway.get_client(account_id)
if client is None:
return None
return await client.get_page(page_id)
async def update_page(
self,
page_id: str,
title: str,
content: str,
space_id: str,
current_version: int,
version_comment: str = "AI 自动更新",
account_id: str = "default",
) -> OutboundResult:
client = self._gateway.get_client(account_id)
if client is None:
return OutboundResult(success=False, error="Client not found")
adf_body = ADFBuilder.from_markdown(content)
payload = {
"id": page_id,
"status": "current",
"title": title,
"spaceId": space_id,
"body": {
"representation": "atlas_doc_format",
"value": adf_body,
},
"version": {
"number": current_version + 1,
"message": version_comment,
},
}
return await self._send_with_retry(client, "PUT", f"/wiki/api/v2/pages/{page_id}", payload)
async def update_page_with_conflict_retry(
self,
page_id: str,
title: str,
content: str,
space_id: str,
version_comment: str = "AI 自动更新",
account_id: str = "default",
max_retries: int = 3,
) -> OutboundResult:
for attempt in range(max_retries):
page = await self.read_page(page_id, account_id)
if page is None:
return OutboundResult(success=False, error="Page not found")
current_version = page.get("version", {}).get("number", 1)
current_title = page.get("title", title)
result = await self.update_page(
page_id, current_title, content, space_id,
current_version, version_comment, account_id,
)
if result.success:
return result
if "409" in result.error or "conflict" in result.error.lower():
delay = RETRY_BASE_DELAY * (2 ** attempt)
logger.info(
"Page update conflict, retry %d/%d after %.1fs",
attempt + 1, max_retries, delay,
)
await asyncio.sleep(delay)
continue
return result
return OutboundResult(success=False, error="Max retries exceeded for update_page")
async def search_pages(
self,
query: str,
space_keys: list[str] | None = None,
limit: int = 10,
account_id: str = "default",
) -> list[dict]:
client = self._gateway.get_client(account_id)
if client is None:
return []
cql = CQLBuilder.search_pages(query, space_keys)
try:
results = await client.cql_search(cql, limit=limit, expand="body.view")
return results.get("results", [])
except Exception as e:
logger.error("CQL search failed for query '%s': %s", query, e)
return []
async def _send_with_retry(
self,
client,
method: str,
path: str,
payload: dict,
) -> OutboundResult:
for attempt in range(MAX_RETRY):
try:
result = await client._request(method, path, json=payload)
comment_id = result.get("id", "")
return OutboundResult(success=True, comment_id=comment_id)
except Exception as e:
status_code = getattr(getattr(e, "response", None), "status_code", 0)
if status_code == 409:
return OutboundResult(success=False, error=f"409 Conflict: {e}")
if status_code == 403:
return OutboundResult(success=False, error=f"403 Forbidden (permissions): {e}")
if attempt == MAX_RETRY - 1:
return OutboundResult(success=False, error=str(e))
delay = RETRY_BASE_DELAY * (2 ** attempt)
logger.warning(
"Outbound retry %d/%d after %.1fs: %s",
attempt + 1, MAX_RETRY, delay, e,
)
await asyncio.sleep(delay)
return OutboundResult(success=False, error="Unknown retry failure")
def chunker(self, text: str, limit: int = 2000, ctx=None) -> list[str]:
chunks = []
paragraphs = text.split("\n\n")
current = ""
for para in paragraphs:
if len(current) + len(para) + 2 > limit and current:
chunks.append(current.rstrip())
current = para
else:
current = f"{current}\n\n{para}" if current else para
if current:
chunks.append(current.rstrip())
return chunks