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