feat: Add Atlassian tools and agentic tool-calling loop
- Add AtlassianClient class: fetches per-user OAuth tokens from portal, calls Jira and Confluence REST APIs on behalf of users - Add 7 Atlassian tools: confluence_search, confluence_read_page, jira_search, jira_get_issue, jira_create_issue, jira_add_comment, jira_transition - Replace single LLM call with agentic loop (max 5 iterations) that feeds tool results back to the model - Add PORTAL_URL and BOT_API_KEY env vars to docker-compose - Update system prompt with Atlassian tool guidance Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
472
bot.py
472
bot.py
@@ -67,6 +67,9 @@ MEMORY_SERVICE_URL = os.environ.get("MEMORY_SERVICE_URL", "http://memory-service
|
|||||||
CONFLUENCE_URL = os.environ.get("CONFLUENCE_BASE_URL", "")
|
CONFLUENCE_URL = os.environ.get("CONFLUENCE_BASE_URL", "")
|
||||||
CONFLUENCE_USER = os.environ.get("CONFLUENCE_USER", "")
|
CONFLUENCE_USER = os.environ.get("CONFLUENCE_USER", "")
|
||||||
CONFLUENCE_TOKEN = os.environ.get("CONFLUENCE_TOKEN", "")
|
CONFLUENCE_TOKEN = os.environ.get("CONFLUENCE_TOKEN", "")
|
||||||
|
PORTAL_URL = os.environ.get("PORTAL_URL", "")
|
||||||
|
BOT_API_KEY = os.environ.get("BOT_API_KEY", "")
|
||||||
|
MAX_TOOL_ITERATIONS = 5
|
||||||
|
|
||||||
SYSTEM_PROMPT = """You are a helpful AI assistant in a Matrix chat room.
|
SYSTEM_PROMPT = """You are a helpful AI assistant in a Matrix chat room.
|
||||||
Keep answers concise but thorough. Use markdown formatting when helpful.
|
Keep answers concise but thorough. Use markdown formatting when helpful.
|
||||||
@@ -84,6 +87,9 @@ IMPORTANT RULES — FOLLOW THESE STRICTLY:
|
|||||||
- You can see and analyze images that users send. Describe what you see when asked about an image.
|
- You can see and analyze images that users send. Describe what you see when asked about an image.
|
||||||
- You can read and analyze PDF documents that users send. Summarize content and answer questions about them.
|
- You can read and analyze PDF documents that users send. Summarize content and answer questions about them.
|
||||||
- You can generate images when asked — use the generate_image tool for any image creation, drawing, or illustration requests.
|
- You can generate images when asked — use the generate_image tool for any image creation, drawing, or illustration requests.
|
||||||
|
- You can search Confluence and Jira using tools. When users ask about documentation, wiki pages, tickets, or tasks, use the appropriate tool.
|
||||||
|
- When creating Jira issues, always confirm the project key and summary with the user before creating.
|
||||||
|
- If a user's Atlassian account is not connected, tell them to connect it at matrixhost.eu/settings and provide the link.
|
||||||
- If user memories are provided, use them to personalize responses. Address users by name if known.
|
- If user memories are provided, use them to personalize responses. Address users by name if known.
|
||||||
- When asked to translate, provide ONLY the translation with no explanation."""
|
- When asked to translate, provide ONLY the translation with no explanation."""
|
||||||
|
|
||||||
@@ -102,6 +108,122 @@ IMAGE_GEN_TOOLS = [{
|
|||||||
}
|
}
|
||||||
}]
|
}]
|
||||||
|
|
||||||
|
ATLASSIAN_TOOLS = [
|
||||||
|
{
|
||||||
|
"type": "function",
|
||||||
|
"function": {
|
||||||
|
"name": "confluence_search",
|
||||||
|
"description": "Search Confluence wiki pages using CQL (Confluence Query Language). Use when the user asks about documentation, wiki, or knowledge base content.",
|
||||||
|
"parameters": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"query": {"type": "string", "description": "Search query text"},
|
||||||
|
"limit": {"type": "integer", "description": "Max results to return (default 5)", "default": 5},
|
||||||
|
},
|
||||||
|
"required": ["query"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "function",
|
||||||
|
"function": {
|
||||||
|
"name": "confluence_read_page",
|
||||||
|
"description": "Read the full content of a Confluence page by its ID. Use after searching to get page details.",
|
||||||
|
"parameters": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"page_id": {"type": "string", "description": "The Confluence page ID"},
|
||||||
|
},
|
||||||
|
"required": ["page_id"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "function",
|
||||||
|
"function": {
|
||||||
|
"name": "jira_search",
|
||||||
|
"description": "Search Jira issues using JQL (Jira Query Language). Use when the user asks about tickets, tasks, bugs, or project issues.",
|
||||||
|
"parameters": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"jql": {"type": "string", "description": "JQL query string"},
|
||||||
|
"limit": {"type": "integer", "description": "Max results (default 10)", "default": 10},
|
||||||
|
},
|
||||||
|
"required": ["jql"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "function",
|
||||||
|
"function": {
|
||||||
|
"name": "jira_get_issue",
|
||||||
|
"description": "Get detailed information about a specific Jira issue by its key (e.g. CF-123).",
|
||||||
|
"parameters": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"issue_key": {"type": "string", "description": "Jira issue key like CF-123"},
|
||||||
|
},
|
||||||
|
"required": ["issue_key"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "function",
|
||||||
|
"function": {
|
||||||
|
"name": "jira_create_issue",
|
||||||
|
"description": "Create a new Jira issue. Always confirm project and summary with user before creating.",
|
||||||
|
"parameters": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"project": {"type": "string", "description": "Project key (e.g. CF)"},
|
||||||
|
"summary": {"type": "string", "description": "Issue title/summary"},
|
||||||
|
"issue_type": {"type": "string", "description": "Issue type (default: Task)", "default": "Task"},
|
||||||
|
"description": {"type": "string", "description": "Issue description (optional)"},
|
||||||
|
},
|
||||||
|
"required": ["project", "summary"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "function",
|
||||||
|
"function": {
|
||||||
|
"name": "jira_add_comment",
|
||||||
|
"description": "Add a comment to an existing Jira issue.",
|
||||||
|
"parameters": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"issue_key": {"type": "string", "description": "Jira issue key like CF-123"},
|
||||||
|
"comment": {"type": "string", "description": "Comment text"},
|
||||||
|
},
|
||||||
|
"required": ["issue_key", "comment"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "function",
|
||||||
|
"function": {
|
||||||
|
"name": "jira_transition",
|
||||||
|
"description": "Change the status of a Jira issue (e.g. move to 'In Progress', 'Done').",
|
||||||
|
"parameters": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"issue_key": {"type": "string", "description": "Jira issue key like CF-123"},
|
||||||
|
"status": {"type": "string", "description": "Target status name (e.g. 'In Progress', 'Done')"},
|
||||||
|
},
|
||||||
|
"required": ["issue_key", "status"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
ALL_TOOLS = IMAGE_GEN_TOOLS + ATLASSIAN_TOOLS
|
||||||
|
|
||||||
|
ATLASSIAN_NOT_CONNECTED_MSG = (
|
||||||
|
"Your Atlassian account is not connected. "
|
||||||
|
"Please connect it at [matrixhost.eu/settings](https://matrixhost.eu/settings?connect=atlassian) "
|
||||||
|
"to use Jira and Confluence features."
|
||||||
|
)
|
||||||
|
|
||||||
HELP_TEXT = """**AI Bot Commands**
|
HELP_TEXT = """**AI Bot Commands**
|
||||||
- `!ai help` — Show this help
|
- `!ai help` — Show this help
|
||||||
- `!ai models` — List available models
|
- `!ai models` — List available models
|
||||||
@@ -254,6 +376,281 @@ class MemoryClient:
|
|||||||
return []
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
class AtlassianClient:
|
||||||
|
"""Fetches per-user Atlassian tokens from the portal and calls Atlassian REST APIs."""
|
||||||
|
|
||||||
|
def __init__(self, portal_url: str, bot_api_key: str):
|
||||||
|
self.portal_url = portal_url.rstrip("/")
|
||||||
|
self.bot_api_key = bot_api_key
|
||||||
|
self.enabled = bool(portal_url and bot_api_key)
|
||||||
|
# Cache cloud IDs per user token to avoid repeated lookups
|
||||||
|
self._cloud_id_cache: dict[str, str] = {}
|
||||||
|
|
||||||
|
async def get_token(self, matrix_user_id: str) -> str | None:
|
||||||
|
"""Fetch user's Atlassian token from the portal."""
|
||||||
|
if not self.enabled:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||||
|
resp = await client.get(
|
||||||
|
f"{self.portal_url}/api/bot/tokens",
|
||||||
|
params={"matrix_user_id": matrix_user_id, "provider": "atlassian"},
|
||||||
|
headers={"Authorization": f"Bearer {self.bot_api_key}"},
|
||||||
|
)
|
||||||
|
resp.raise_for_status()
|
||||||
|
data = resp.json()
|
||||||
|
return data.get("access_token") if data.get("connected") else None
|
||||||
|
except Exception:
|
||||||
|
logger.warning("Failed to fetch Atlassian token for %s", matrix_user_id, exc_info=True)
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def _get_cloud_id(self, token: str) -> str | None:
|
||||||
|
"""Get the Atlassian Cloud ID for the user's instance."""
|
||||||
|
if token in self._cloud_id_cache:
|
||||||
|
return self._cloud_id_cache[token]
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||||
|
resp = await client.get(
|
||||||
|
"https://api.atlassian.com/oauth/token/accessible-resources",
|
||||||
|
headers={"Authorization": f"Bearer {token}"},
|
||||||
|
)
|
||||||
|
resp.raise_for_status()
|
||||||
|
resources = resp.json()
|
||||||
|
if resources:
|
||||||
|
cloud_id = resources[0]["id"]
|
||||||
|
self._cloud_id_cache[token] = cloud_id
|
||||||
|
return cloud_id
|
||||||
|
except Exception:
|
||||||
|
logger.warning("Failed to fetch Atlassian cloud ID", exc_info=True)
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def confluence_search(self, token: str, query: str, limit: int = 5) -> str:
|
||||||
|
cloud_id = await self._get_cloud_id(token)
|
||||||
|
if not cloud_id:
|
||||||
|
return "Error: Could not determine Atlassian Cloud instance."
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=15.0) as client:
|
||||||
|
resp = await client.get(
|
||||||
|
f"https://api.atlassian.com/ex/confluence/{cloud_id}/wiki/rest/api/content/search",
|
||||||
|
params={"cql": f'text ~ "{query}"', "limit": str(limit), "expand": "metadata.labels"},
|
||||||
|
headers={"Authorization": f"Bearer {token}"},
|
||||||
|
)
|
||||||
|
resp.raise_for_status()
|
||||||
|
data = resp.json()
|
||||||
|
results = data.get("results", [])
|
||||||
|
if not results:
|
||||||
|
return f"No Confluence pages found for: {query}"
|
||||||
|
lines = []
|
||||||
|
for r in results:
|
||||||
|
title = r.get("title", "Untitled")
|
||||||
|
page_id = r.get("id", "")
|
||||||
|
space = r.get("_expandable", {}).get("space", "").split("/")[-1]
|
||||||
|
url = f"https://api.atlassian.com/ex/confluence/{cloud_id}/wiki{r.get('_links', {}).get('webui', '')}"
|
||||||
|
lines.append(f"- **{title}** (ID: {page_id}, Space: {space})\n {url}")
|
||||||
|
return f"Found {len(results)} pages:\n" + "\n".join(lines)
|
||||||
|
except Exception as e:
|
||||||
|
return f"Confluence search error: {e}"
|
||||||
|
|
||||||
|
async def confluence_read_page(self, token: str, page_id: str) -> str:
|
||||||
|
cloud_id = await self._get_cloud_id(token)
|
||||||
|
if not cloud_id:
|
||||||
|
return "Error: Could not determine Atlassian Cloud instance."
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=15.0) as client:
|
||||||
|
resp = await client.get(
|
||||||
|
f"https://api.atlassian.com/ex/confluence/{cloud_id}/wiki/rest/api/content/{page_id}",
|
||||||
|
params={"expand": "body.storage,version"},
|
||||||
|
headers={"Authorization": f"Bearer {token}"},
|
||||||
|
)
|
||||||
|
resp.raise_for_status()
|
||||||
|
data = resp.json()
|
||||||
|
title = data.get("title", "Untitled")
|
||||||
|
html_body = data.get("body", {}).get("storage", {}).get("value", "")
|
||||||
|
# Strip HTML tags for a readable summary
|
||||||
|
from bs4 import BeautifulSoup
|
||||||
|
text = BeautifulSoup(html_body, "lxml").get_text(separator="\n", strip=True)
|
||||||
|
# Truncate for LLM context
|
||||||
|
if len(text) > 8000:
|
||||||
|
text = text[:8000] + "\n...(truncated)"
|
||||||
|
return f"**{title}**\n\n{text}"
|
||||||
|
except Exception as e:
|
||||||
|
return f"Failed to read Confluence page {page_id}: {e}"
|
||||||
|
|
||||||
|
async def jira_search(self, token: str, jql: str, limit: int = 10) -> str:
|
||||||
|
cloud_id = await self._get_cloud_id(token)
|
||||||
|
if not cloud_id:
|
||||||
|
return "Error: Could not determine Atlassian Cloud instance."
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=15.0) as client:
|
||||||
|
resp = await client.get(
|
||||||
|
f"https://api.atlassian.com/ex/jira/{cloud_id}/rest/api/3/search/jql",
|
||||||
|
params={"jql": jql, "maxResults": str(limit), "fields": "summary,status,assignee,priority,created,updated"},
|
||||||
|
headers={"Authorization": f"Bearer {token}"},
|
||||||
|
)
|
||||||
|
resp.raise_for_status()
|
||||||
|
data = resp.json()
|
||||||
|
issues = data.get("issues", [])
|
||||||
|
if not issues:
|
||||||
|
return f"No Jira issues found for: {jql}"
|
||||||
|
lines = []
|
||||||
|
for issue in issues:
|
||||||
|
key = issue["key"]
|
||||||
|
fields = issue.get("fields", {})
|
||||||
|
summary = fields.get("summary", "")
|
||||||
|
status = fields.get("status", {}).get("name", "")
|
||||||
|
assignee = fields.get("assignee", {})
|
||||||
|
assignee_name = assignee.get("displayName", "Unassigned") if assignee else "Unassigned"
|
||||||
|
priority = fields.get("priority", {}).get("name", "") if fields.get("priority") else ""
|
||||||
|
lines.append(f"- **{key}**: {summary} [{status}] (Assignee: {assignee_name}, Priority: {priority})")
|
||||||
|
return f"Found {data.get('total', len(issues))} issues:\n" + "\n".join(lines)
|
||||||
|
except Exception as e:
|
||||||
|
return f"Jira search error: {e}"
|
||||||
|
|
||||||
|
async def jira_get_issue(self, token: str, issue_key: str) -> str:
|
||||||
|
cloud_id = await self._get_cloud_id(token)
|
||||||
|
if not cloud_id:
|
||||||
|
return "Error: Could not determine Atlassian Cloud instance."
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=15.0) as client:
|
||||||
|
resp = await client.get(
|
||||||
|
f"https://api.atlassian.com/ex/jira/{cloud_id}/rest/api/3/issue/{issue_key}",
|
||||||
|
params={"fields": "summary,status,assignee,priority,description,created,updated,comment"},
|
||||||
|
headers={"Authorization": f"Bearer {token}"},
|
||||||
|
)
|
||||||
|
resp.raise_for_status()
|
||||||
|
data = resp.json()
|
||||||
|
fields = data.get("fields", {})
|
||||||
|
summary = fields.get("summary", "")
|
||||||
|
status = fields.get("status", {}).get("name", "")
|
||||||
|
assignee = fields.get("assignee", {})
|
||||||
|
assignee_name = assignee.get("displayName", "Unassigned") if assignee else "Unassigned"
|
||||||
|
priority = fields.get("priority", {}).get("name", "") if fields.get("priority") else ""
|
||||||
|
desc = fields.get("description")
|
||||||
|
# ADF description — extract text nodes
|
||||||
|
desc_text = ""
|
||||||
|
if desc and isinstance(desc, dict):
|
||||||
|
desc_text = self._extract_adf_text(desc)
|
||||||
|
elif isinstance(desc, str):
|
||||||
|
desc_text = desc
|
||||||
|
if len(desc_text) > 4000:
|
||||||
|
desc_text = desc_text[:4000] + "...(truncated)"
|
||||||
|
comments = fields.get("comment", {}).get("comments", [])
|
||||||
|
comment_lines = []
|
||||||
|
for c in comments[-5:]: # Last 5 comments
|
||||||
|
author = c.get("author", {}).get("displayName", "Unknown")
|
||||||
|
body = self._extract_adf_text(c.get("body", {})) if isinstance(c.get("body"), dict) else str(c.get("body", ""))
|
||||||
|
comment_lines.append(f" - **{author}**: {body[:500]}")
|
||||||
|
result = f"**{issue_key}: {summary}**\nStatus: {status} | Assignee: {assignee_name} | Priority: {priority}"
|
||||||
|
if desc_text:
|
||||||
|
result += f"\n\nDescription:\n{desc_text}"
|
||||||
|
if comment_lines:
|
||||||
|
result += f"\n\nRecent comments:\n" + "\n".join(comment_lines)
|
||||||
|
return result
|
||||||
|
except Exception as e:
|
||||||
|
return f"Failed to fetch {issue_key}: {e}"
|
||||||
|
|
||||||
|
async def jira_create_issue(self, token: str, project: str, summary: str,
|
||||||
|
issue_type: str = "Task", description: str = "") -> str:
|
||||||
|
cloud_id = await self._get_cloud_id(token)
|
||||||
|
if not cloud_id:
|
||||||
|
return "Error: Could not determine Atlassian Cloud instance."
|
||||||
|
try:
|
||||||
|
body: dict = {
|
||||||
|
"fields": {
|
||||||
|
"project": {"key": project},
|
||||||
|
"summary": summary,
|
||||||
|
"issuetype": {"name": issue_type},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if description:
|
||||||
|
body["fields"]["description"] = {
|
||||||
|
"type": "doc",
|
||||||
|
"version": 1,
|
||||||
|
"content": [{"type": "paragraph", "content": [{"type": "text", "text": description}]}],
|
||||||
|
}
|
||||||
|
async with httpx.AsyncClient(timeout=15.0) as client:
|
||||||
|
resp = await client.post(
|
||||||
|
f"https://api.atlassian.com/ex/jira/{cloud_id}/rest/api/3/issue",
|
||||||
|
json=body,
|
||||||
|
headers={"Authorization": f"Bearer {token}", "Content-Type": "application/json"},
|
||||||
|
)
|
||||||
|
resp.raise_for_status()
|
||||||
|
data = resp.json()
|
||||||
|
return f"Created **{data['key']}**: {summary}"
|
||||||
|
except httpx.HTTPStatusError as e:
|
||||||
|
return f"Failed to create issue: {e.response.text}"
|
||||||
|
except Exception as e:
|
||||||
|
return f"Failed to create issue: {e}"
|
||||||
|
|
||||||
|
async def jira_add_comment(self, token: str, issue_key: str, comment: str) -> str:
|
||||||
|
cloud_id = await self._get_cloud_id(token)
|
||||||
|
if not cloud_id:
|
||||||
|
return "Error: Could not determine Atlassian Cloud instance."
|
||||||
|
try:
|
||||||
|
body = {
|
||||||
|
"body": {
|
||||||
|
"type": "doc",
|
||||||
|
"version": 1,
|
||||||
|
"content": [{"type": "paragraph", "content": [{"type": "text", "text": comment}]}],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async with httpx.AsyncClient(timeout=15.0) as client:
|
||||||
|
resp = await client.post(
|
||||||
|
f"https://api.atlassian.com/ex/jira/{cloud_id}/rest/api/3/issue/{issue_key}/comment",
|
||||||
|
json=body,
|
||||||
|
headers={"Authorization": f"Bearer {token}", "Content-Type": "application/json"},
|
||||||
|
)
|
||||||
|
resp.raise_for_status()
|
||||||
|
return f"Comment added to {issue_key}."
|
||||||
|
except Exception as e:
|
||||||
|
return f"Failed to add comment to {issue_key}: {e}"
|
||||||
|
|
||||||
|
async def jira_transition(self, token: str, issue_key: str, status: str) -> str:
|
||||||
|
cloud_id = await self._get_cloud_id(token)
|
||||||
|
if not cloud_id:
|
||||||
|
return "Error: Could not determine Atlassian Cloud instance."
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=15.0) as client:
|
||||||
|
# First, get available transitions
|
||||||
|
resp = await client.get(
|
||||||
|
f"https://api.atlassian.com/ex/jira/{cloud_id}/rest/api/3/issue/{issue_key}/transitions",
|
||||||
|
headers={"Authorization": f"Bearer {token}"},
|
||||||
|
)
|
||||||
|
resp.raise_for_status()
|
||||||
|
transitions = resp.json().get("transitions", [])
|
||||||
|
# Find matching transition (case-insensitive)
|
||||||
|
target = None
|
||||||
|
for t in transitions:
|
||||||
|
if t["name"].lower() == status.lower() or t["to"]["name"].lower() == status.lower():
|
||||||
|
target = t
|
||||||
|
break
|
||||||
|
if not target:
|
||||||
|
available = ", ".join(t["name"] for t in transitions)
|
||||||
|
return f"Cannot transition {issue_key} to '{status}'. Available transitions: {available}"
|
||||||
|
# Execute transition
|
||||||
|
resp = await client.post(
|
||||||
|
f"https://api.atlassian.com/ex/jira/{cloud_id}/rest/api/3/issue/{issue_key}/transitions",
|
||||||
|
json={"transition": {"id": target["id"]}},
|
||||||
|
headers={"Authorization": f"Bearer {token}", "Content-Type": "application/json"},
|
||||||
|
)
|
||||||
|
resp.raise_for_status()
|
||||||
|
return f"Transitioned {issue_key} to **{target['to']['name']}**."
|
||||||
|
except Exception as e:
|
||||||
|
return f"Failed to transition {issue_key}: {e}"
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _extract_adf_text(adf: dict) -> str:
|
||||||
|
"""Recursively extract text from Atlassian Document Format."""
|
||||||
|
if not isinstance(adf, dict):
|
||||||
|
return str(adf)
|
||||||
|
parts = []
|
||||||
|
if adf.get("type") == "text":
|
||||||
|
parts.append(adf.get("text", ""))
|
||||||
|
for child in adf.get("content", []):
|
||||||
|
parts.append(AtlassianClient._extract_adf_text(child))
|
||||||
|
return " ".join(parts).strip()
|
||||||
|
|
||||||
|
|
||||||
class Bot:
|
class Bot:
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
config = AsyncClientConfig(
|
config = AsyncClientConfig(
|
||||||
@@ -274,6 +671,7 @@ class Bot:
|
|||||||
self.active_callers: dict[str, set[str]] = {} # room_id → set of caller user IDs
|
self.active_callers: dict[str, set[str]] = {} # room_id → set of caller user IDs
|
||||||
self.rag = DocumentRAG(WILDFILES_BASE_URL, WILDFILES_ORG)
|
self.rag = DocumentRAG(WILDFILES_BASE_URL, WILDFILES_ORG)
|
||||||
self.memory = MemoryClient(MEMORY_SERVICE_URL)
|
self.memory = MemoryClient(MEMORY_SERVICE_URL)
|
||||||
|
self.atlassian = AtlassianClient(PORTAL_URL, BOT_API_KEY)
|
||||||
self.llm = AsyncOpenAI(base_url=LITELLM_URL, api_key=LITELLM_KEY) if LITELLM_URL else None
|
self.llm = AsyncOpenAI(base_url=LITELLM_URL, api_key=LITELLM_KEY) if LITELLM_URL else None
|
||||||
self.user_keys: dict[str, str] = self._load_user_keys() # matrix_user_id -> api_key
|
self.user_keys: dict[str, str] = self._load_user_keys() # matrix_user_id -> api_key
|
||||||
self.room_models: dict[str, str] = {} # room_id -> model name
|
self.room_models: dict[str, str] = {} # room_id -> model name
|
||||||
@@ -1495,6 +1893,38 @@ class Bot:
|
|||||||
finally:
|
finally:
|
||||||
self._pending_connects.pop(sender, None)
|
self._pending_connects.pop(sender, None)
|
||||||
|
|
||||||
|
async def _execute_tool(self, tool_name: str, args: dict, sender: str, room_id: str) -> str:
|
||||||
|
"""Execute a tool call and return the result as a string."""
|
||||||
|
# Image generation — no Atlassian token needed
|
||||||
|
if tool_name == "generate_image":
|
||||||
|
await self._generate_and_send_image(room_id, args.get("prompt", ""))
|
||||||
|
return "Image generated and sent to the room."
|
||||||
|
|
||||||
|
# Atlassian tools — need per-user token
|
||||||
|
token = await self.atlassian.get_token(sender) if sender else None
|
||||||
|
if not token:
|
||||||
|
return ATLASSIAN_NOT_CONNECTED_MSG
|
||||||
|
|
||||||
|
if tool_name == "confluence_search":
|
||||||
|
return await self.atlassian.confluence_search(token, args["query"], args.get("limit", 5))
|
||||||
|
elif tool_name == "confluence_read_page":
|
||||||
|
return await self.atlassian.confluence_read_page(token, args["page_id"])
|
||||||
|
elif tool_name == "jira_search":
|
||||||
|
return await self.atlassian.jira_search(token, args["jql"], args.get("limit", 10))
|
||||||
|
elif tool_name == "jira_get_issue":
|
||||||
|
return await self.atlassian.jira_get_issue(token, args["issue_key"])
|
||||||
|
elif tool_name == "jira_create_issue":
|
||||||
|
return await self.atlassian.jira_create_issue(
|
||||||
|
token, args["project"], args["summary"],
|
||||||
|
args.get("issue_type", "Task"), args.get("description", ""),
|
||||||
|
)
|
||||||
|
elif tool_name == "jira_add_comment":
|
||||||
|
return await self.atlassian.jira_add_comment(token, args["issue_key"], args["comment"])
|
||||||
|
elif tool_name == "jira_transition":
|
||||||
|
return await self.atlassian.jira_transition(token, args["issue_key"], args["status"])
|
||||||
|
else:
|
||||||
|
return f"Unknown tool: {tool_name}"
|
||||||
|
|
||||||
async def _respond_with_ai(self, room, user_message: str, sender: str = None, image_data: tuple = None) -> str | None:
|
async def _respond_with_ai(self, room, user_message: str, sender: str = None, image_data: tuple = None) -> str | None:
|
||||||
"""Send AI response and return the reply text (or None on failure)."""
|
"""Send AI response and return the reply text (or None on failure)."""
|
||||||
model = self.room_models.get(room.room_id, DEFAULT_MODEL)
|
model = self.room_models.get(room.room_id, DEFAULT_MODEL)
|
||||||
@@ -1569,25 +1999,55 @@ class Bot:
|
|||||||
else:
|
else:
|
||||||
messages.append({"role": "user", "content": user_message})
|
messages.append({"role": "user", "content": user_message})
|
||||||
|
|
||||||
|
# Determine available tools (no tools when analyzing images)
|
||||||
|
tools = ALL_TOOLS if not image_data else None
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
reply = ""
|
||||||
|
|
||||||
|
# Agentic tool-calling loop: iterate up to MAX_TOOL_ITERATIONS
|
||||||
|
for iteration in range(MAX_TOOL_ITERATIONS):
|
||||||
resp = await self.llm.chat.completions.create(
|
resp = await self.llm.chat.completions.create(
|
||||||
model=model,
|
model=model,
|
||||||
messages=messages,
|
messages=messages,
|
||||||
max_tokens=2048,
|
max_tokens=2048,
|
||||||
tools=IMAGE_GEN_TOOLS if not image_data else None,
|
tools=tools,
|
||||||
)
|
)
|
||||||
choice = resp.choices[0]
|
choice = resp.choices[0]
|
||||||
reply = choice.message.content or ""
|
reply = choice.message.content or ""
|
||||||
|
|
||||||
if choice.message.tool_calls:
|
if not choice.message.tool_calls:
|
||||||
|
# No tool calls — final text response
|
||||||
|
break
|
||||||
|
|
||||||
|
# Process tool calls and feed results back
|
||||||
|
# Append the assistant message with tool_calls
|
||||||
|
assistant_msg = {"role": "assistant", "content": reply or None, "tool_calls": []}
|
||||||
for tc in choice.message.tool_calls:
|
for tc in choice.message.tool_calls:
|
||||||
if tc.function.name == "generate_image":
|
assistant_msg["tool_calls"].append({
|
||||||
|
"id": tc.id,
|
||||||
|
"type": "function",
|
||||||
|
"function": {"name": tc.function.name, "arguments": tc.function.arguments},
|
||||||
|
})
|
||||||
|
messages.append(assistant_msg)
|
||||||
|
|
||||||
|
# Execute each tool and append results
|
||||||
|
for tc in choice.message.tool_calls:
|
||||||
|
try:
|
||||||
args = json.loads(tc.function.arguments)
|
args = json.loads(tc.function.arguments)
|
||||||
await self._generate_and_send_image(room.room_id, args["prompt"])
|
except json.JSONDecodeError:
|
||||||
|
args = {}
|
||||||
|
result = await self._execute_tool(tc.function.name, args, sender, room.room_id)
|
||||||
|
messages.append({
|
||||||
|
"role": "tool",
|
||||||
|
"tool_call_id": tc.id,
|
||||||
|
"content": result,
|
||||||
|
})
|
||||||
|
logger.info("Tool %s executed (iter %d) for %s", tc.function.name, iteration, sender)
|
||||||
|
|
||||||
|
# Send final reply
|
||||||
if reply:
|
if reply:
|
||||||
await self._send_text(room.room_id, reply)
|
await self._send_text(room.room_id, reply)
|
||||||
else:
|
|
||||||
await self._send_text(room.room_id, reply)
|
|
||||||
|
|
||||||
# Extract and store new memories (after reply sent, with timeout)
|
# Extract and store new memories (after reply sent, with timeout)
|
||||||
if sender and reply:
|
if sender and reply:
|
||||||
|
|||||||
@@ -22,6 +22,8 @@ services:
|
|||||||
- WILDFILES_BASE_URL
|
- WILDFILES_BASE_URL
|
||||||
- WILDFILES_ORG
|
- WILDFILES_ORG
|
||||||
- MEMORY_SERVICE_URL=http://memory-service:8090
|
- MEMORY_SERVICE_URL=http://memory-service:8090
|
||||||
|
- PORTAL_URL
|
||||||
|
- BOT_API_KEY
|
||||||
volumes:
|
volumes:
|
||||||
- bot-data:/data
|
- bot-data:/data
|
||||||
depends_on:
|
depends_on:
|
||||||
|
|||||||
Reference in New Issue
Block a user