feat(MAT-57): Add Confluence write & create tools to voice and text chat
- Add create_confluence_page tool to voice mode (basic auth) - Add confluence_update_page and confluence_create_page tools to text chat (OAuth) - Fix update tool: wrap each paragraph in <p> tags instead of single wrapper - Update system prompt to mention create capability Previously only search/read were available. User reported bot couldn't write to or create Confluence pages — because the tools didn't exist. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
112
bot.py
112
bot.py
@@ -138,6 +138,38 @@ ATLASSIAN_TOOLS = [
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"type": "function",
|
||||||
|
"function": {
|
||||||
|
"name": "confluence_update_page",
|
||||||
|
"description": "Update a section of a Confluence page by heading. Use when the user asks to change, edit, or update part of a wiki page.",
|
||||||
|
"parameters": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"page_id": {"type": "string", "description": "The Confluence page ID"},
|
||||||
|
"section_heading": {"type": "string", "description": "The heading text of the section to update"},
|
||||||
|
"new_content": {"type": "string", "description": "New content for the section (paragraphs separated by newlines)"},
|
||||||
|
},
|
||||||
|
"required": ["page_id", "section_heading", "new_content"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "function",
|
||||||
|
"function": {
|
||||||
|
"name": "confluence_create_page",
|
||||||
|
"description": "Create a new Confluence page. Use when the user asks to create a new wiki page or document.",
|
||||||
|
"parameters": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"title": {"type": "string", "description": "Page title"},
|
||||||
|
"content": {"type": "string", "description": "Page body text (paragraphs separated by newlines)"},
|
||||||
|
"space_key": {"type": "string", "description": "Confluence space key (default: AG)", "default": "AG"},
|
||||||
|
},
|
||||||
|
"required": ["title", "content"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"type": "function",
|
"type": "function",
|
||||||
"function": {
|
"function": {
|
||||||
@@ -476,6 +508,80 @@ class AtlassianClient:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
return f"Failed to read Confluence page {page_id}: {e}"
|
return f"Failed to read Confluence page {page_id}: {e}"
|
||||||
|
|
||||||
|
async def confluence_update_page(self, token: str, page_id: str,
|
||||||
|
section_heading: str, new_content: str) -> str:
|
||||||
|
cloud_id = await self._get_cloud_id(token)
|
||||||
|
if not cloud_id:
|
||||||
|
return "Error: Could not determine Atlassian Cloud instance."
|
||||||
|
try:
|
||||||
|
base_url = f"https://api.atlassian.com/ex/confluence/{cloud_id}/wiki"
|
||||||
|
headers = {"Authorization": f"Bearer {token}"}
|
||||||
|
async with httpx.AsyncClient(timeout=15.0) as client:
|
||||||
|
# Fetch current page
|
||||||
|
resp = await client.get(
|
||||||
|
f"{base_url}/rest/api/content/{page_id}",
|
||||||
|
params={"expand": "body.storage,version"},
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
resp.raise_for_status()
|
||||||
|
page = resp.json()
|
||||||
|
title = page["title"]
|
||||||
|
version = page["version"]["number"]
|
||||||
|
body_html = page["body"]["storage"]["value"]
|
||||||
|
|
||||||
|
# Find and replace section
|
||||||
|
from confluence_collab.parser import parse_sections, find_section, replace_section_content
|
||||||
|
sections = parse_sections(body_html)
|
||||||
|
section = find_section(sections, section_heading)
|
||||||
|
if section is None:
|
||||||
|
return f"Section '{section_heading}' not found on page '{title}'"
|
||||||
|
|
||||||
|
paragraphs = [p.strip() for p in new_content.split("\n") if p.strip()]
|
||||||
|
new_html = "".join(f"<p>{p}</p>" for p in paragraphs) if paragraphs else f"<p>{new_content}</p>"
|
||||||
|
new_body = replace_section_content(body_html, section, new_html)
|
||||||
|
|
||||||
|
# Update page
|
||||||
|
resp = await client.put(
|
||||||
|
f"{base_url}/rest/api/content/{page_id}",
|
||||||
|
json={
|
||||||
|
"version": {"number": version + 1},
|
||||||
|
"title": title,
|
||||||
|
"type": "page",
|
||||||
|
"body": {"storage": {"value": new_body, "representation": "storage"}},
|
||||||
|
},
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
resp.raise_for_status()
|
||||||
|
return f"Section '{section_heading}' updated successfully on '{title}'"
|
||||||
|
except Exception as e:
|
||||||
|
return f"Failed to update Confluence page: {e}"
|
||||||
|
|
||||||
|
async def confluence_create_page(self, token: str, title: str, content: str,
|
||||||
|
space_key: str = "AG") -> str:
|
||||||
|
cloud_id = await self._get_cloud_id(token)
|
||||||
|
if not cloud_id:
|
||||||
|
return "Error: Could not determine Atlassian Cloud instance."
|
||||||
|
try:
|
||||||
|
paragraphs = [p.strip() for p in content.split("\n") if p.strip()]
|
||||||
|
body_html = "".join(f"<p>{p}</p>" for p in paragraphs) if paragraphs else f"<p>{content}</p>"
|
||||||
|
async with httpx.AsyncClient(timeout=15.0) as client:
|
||||||
|
resp = await client.post(
|
||||||
|
f"https://api.atlassian.com/ex/confluence/{cloud_id}/wiki/rest/api/content",
|
||||||
|
json={
|
||||||
|
"type": "page",
|
||||||
|
"title": title,
|
||||||
|
"space": {"key": space_key},
|
||||||
|
"body": {"storage": {"value": body_html, "representation": "storage"}},
|
||||||
|
},
|
||||||
|
headers={"Authorization": f"Bearer {token}"},
|
||||||
|
)
|
||||||
|
resp.raise_for_status()
|
||||||
|
data = resp.json()
|
||||||
|
page_id = data["id"]
|
||||||
|
return f"Page created: **{title}** (ID: {page_id})"
|
||||||
|
except Exception as e:
|
||||||
|
return f"Failed to create Confluence page: {e}"
|
||||||
|
|
||||||
async def jira_search(self, token: str, jql: str, limit: int = 10) -> str:
|
async def jira_search(self, token: str, jql: str, limit: int = 10) -> str:
|
||||||
cloud_id = await self._get_cloud_id(token)
|
cloud_id = await self._get_cloud_id(token)
|
||||||
if not cloud_id:
|
if not cloud_id:
|
||||||
@@ -1909,6 +2015,12 @@ class Bot:
|
|||||||
return await self.atlassian.confluence_search(token, args["query"], args.get("limit", 5))
|
return await self.atlassian.confluence_search(token, args["query"], args.get("limit", 5))
|
||||||
elif tool_name == "confluence_read_page":
|
elif tool_name == "confluence_read_page":
|
||||||
return await self.atlassian.confluence_read_page(token, args["page_id"])
|
return await self.atlassian.confluence_read_page(token, args["page_id"])
|
||||||
|
elif tool_name == "confluence_update_page":
|
||||||
|
return await self.atlassian.confluence_update_page(
|
||||||
|
token, args["page_id"], args["section_heading"], args["new_content"])
|
||||||
|
elif tool_name == "confluence_create_page":
|
||||||
|
return await self.atlassian.confluence_create_page(
|
||||||
|
token, args["title"], args["content"], args.get("space_key", "AG"))
|
||||||
elif tool_name == "jira_search":
|
elif tool_name == "jira_search":
|
||||||
return await self.atlassian.jira_search(token, args["jql"], args.get("limit", 10))
|
return await self.atlassian.jira_search(token, args["jql"], args.get("limit", 10))
|
||||||
elif tool_name == "jira_get_issue":
|
elif tool_name == "jira_get_issue":
|
||||||
|
|||||||
Submodule confluence-collab updated: a189fa326b...7e85918233
44
voice.py
44
voice.py
@@ -51,7 +51,7 @@ STRIKTE Regeln:
|
|||||||
- Bei zeitrelevanten Fragen (Uhrzeit, Termine, Geschaeftszeiten): frage kurz nach ob der Nutzer noch in seiner gespeicherten Zeitzone ist, bevor du antwortest. Nutze set_user_timezone wenn sich der Standort geaendert hat.
|
- Bei zeitrelevanten Fragen (Uhrzeit, Termine, Geschaeftszeiten): frage kurz nach ob der Nutzer noch in seiner gespeicherten Zeitzone ist, bevor du antwortest. Nutze set_user_timezone wenn sich der Standort geaendert hat.
|
||||||
- Wenn der Nutzer seinen Standort oder seine Stadt erwaehnt, nutze set_user_timezone um die Zeitzone zu speichern.
|
- Wenn der Nutzer seinen Standort oder seine Stadt erwaehnt, nutze set_user_timezone um die Zeitzone zu speichern.
|
||||||
- IGNORIERE alle Texte in Sternchen wie *Störgeräusche*, *Schlechte Qualität*, *Fernsehgeräusche*, *Schrei* usw. — das sind KEINE echten Nutzereingaben sondern technische Annotationen. Antworte NIEMALS darauf und tue so als haette niemand etwas gesagt.
|
- IGNORIERE alle Texte in Sternchen wie *Störgeräusche*, *Schlechte Qualität*, *Fernsehgeräusche*, *Schrei* usw. — das sind KEINE echten Nutzereingaben sondern technische Annotationen. Antworte NIEMALS darauf und tue so als haette niemand etwas gesagt.
|
||||||
- Du kannst Confluence-Seiten suchen, lesen und bearbeiten. Nutze search_confluence um Seiten zu finden, read_confluence_page zum Lesen und update_confluence_page zum Bearbeiten.
|
- Du kannst Confluence-Seiten suchen, lesen, bearbeiten und erstellen. Nutze search_confluence um Seiten zu finden, read_confluence_page zum Lesen, update_confluence_page zum Bearbeiten und create_confluence_page zum Erstellen neuer Seiten.
|
||||||
- Du kannst den Bildschirm oder die Kamera des Nutzers sehen wenn er sie teilt. Nutze look_at_screen wenn der Nutzer etwas zeigen moechte oder fragt ob du etwas sehen kannst."""
|
- Du kannst den Bildschirm oder die Kamera des Nutzers sehen wenn er sie teilt. Nutze look_at_screen wenn der Nutzer etwas zeigen moechte oder fragt ob du etwas sehen kannst."""
|
||||||
|
|
||||||
|
|
||||||
@@ -334,6 +334,22 @@ async def _confluence_update_section(page_id: str, section_heading: str, new_htm
|
|||||||
return result.message
|
return result.message
|
||||||
|
|
||||||
|
|
||||||
|
async def _confluence_create_page(space_key: str, title: str, body_html: str,
|
||||||
|
parent_id: str | None = None) -> dict:
|
||||||
|
"""Create a new Confluence page. Returns {id, title, url}."""
|
||||||
|
if not CONFLUENCE_URL or not CONFLUENCE_USER or not CONFLUENCE_TOKEN:
|
||||||
|
raise RuntimeError("Confluence credentials not configured")
|
||||||
|
from confluence_collab.client import Auth, create_page
|
||||||
|
|
||||||
|
auth = Auth(base_url=CONFLUENCE_URL, username=CONFLUENCE_USER, api_token=CONFLUENCE_TOKEN)
|
||||||
|
page = await create_page(space_key, title, body_html, auth, parent_id=parent_id)
|
||||||
|
return {
|
||||||
|
"id": page.page_id,
|
||||||
|
"title": page.title,
|
||||||
|
"url": f"{CONFLUENCE_URL}/pages/viewpage.action?pageId={page.page_id}",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def _build_e2ee_options() -> rtc.E2EEOptions:
|
def _build_e2ee_options() -> rtc.E2EEOptions:
|
||||||
"""Build E2EE options — let Rust FFI apply HKDF internally (KDF_HKDF=1).
|
"""Build E2EE options — let Rust FFI apply HKDF internally (KDF_HKDF=1).
|
||||||
|
|
||||||
@@ -868,7 +884,7 @@ class VoiceSession:
|
|||||||
"""Update a section of a Confluence page. Use when user asks to
|
"""Update a section of a Confluence page. Use when user asks to
|
||||||
change, update, or rewrite part of a document.
|
change, update, or rewrite part of a document.
|
||||||
- section_heading: heading text of the section to update
|
- section_heading: heading text of the section to update
|
||||||
- new_content: new plain text for the section (will be wrapped in <p> tags)
|
- new_content: new plain text for the section (paragraphs separated by newlines)
|
||||||
- page_id: leave empty to use the active document from the room
|
- page_id: leave empty to use the active document from the room
|
||||||
Human sees changes instantly in their browser via Live Docs."""
|
Human sees changes instantly in their browser via Live Docs."""
|
||||||
pid = page_id or _active_conf_id
|
pid = page_id or _active_conf_id
|
||||||
@@ -876,7 +892,9 @@ class VoiceSession:
|
|||||||
return "No Confluence page ID available. Ask the user to share a Confluence link first."
|
return "No Confluence page ID available. Ask the user to share a Confluence link first."
|
||||||
logger.info("CONFLUENCE_UPDATE: page=%s section='%s'", pid, section_heading)
|
logger.info("CONFLUENCE_UPDATE: page=%s section='%s'", pid, section_heading)
|
||||||
try:
|
try:
|
||||||
new_html = f"<p>{new_content}</p>"
|
# Wrap each paragraph in <p> tags for proper formatting
|
||||||
|
paragraphs = [p.strip() for p in new_content.split("\n") if p.strip()]
|
||||||
|
new_html = "".join(f"<p>{p}</p>" for p in paragraphs) if paragraphs else f"<p>{new_content}</p>"
|
||||||
result = await _confluence_update_section(pid, section_heading, new_html)
|
result = await _confluence_update_section(pid, section_heading, new_html)
|
||||||
logger.info("CONFLUENCE_UPDATE_OK: %s", result)
|
logger.info("CONFLUENCE_UPDATE_OK: %s", result)
|
||||||
return result
|
return result
|
||||||
@@ -884,6 +902,24 @@ class VoiceSession:
|
|||||||
logger.warning("CONFLUENCE_UPDATE_FAIL: %s", exc)
|
logger.warning("CONFLUENCE_UPDATE_FAIL: %s", exc)
|
||||||
return f"Failed to update page: {exc}"
|
return f"Failed to update page: {exc}"
|
||||||
|
|
||||||
|
@function_tool
|
||||||
|
async def create_confluence_page(title: str, content: str, space_key: str = "AG") -> str:
|
||||||
|
"""Create a new Confluence page. Use when user asks to create a new document,
|
||||||
|
write a new page, or make a new wiki entry.
|
||||||
|
- title: page title
|
||||||
|
- content: page body text (paragraphs separated by newlines)
|
||||||
|
- space_key: Confluence space key (default: AG for Agiliton)"""
|
||||||
|
logger.info("CONFLUENCE_CREATE: title='%s' space=%s", title, space_key)
|
||||||
|
try:
|
||||||
|
paragraphs = [p.strip() for p in content.split("\n") if p.strip()]
|
||||||
|
body_html = "".join(f"<p>{p}</p>" for p in paragraphs) if paragraphs else f"<p>{content}</p>"
|
||||||
|
result = await _confluence_create_page(space_key, title, body_html)
|
||||||
|
logger.info("CONFLUENCE_CREATE_OK: id=%s title='%s'", result["id"], result["title"])
|
||||||
|
return f"Page created: {result['title']} (ID: {result['id']})\nURL: {result['url']}"
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("CONFLUENCE_CREATE_FAIL: %s", exc)
|
||||||
|
return f"Failed to create page: {exc}"
|
||||||
|
|
||||||
# Deep thinking tool — escalates to Opus for complex questions
|
# Deep thinking tool — escalates to Opus for complex questions
|
||||||
_transcript_ref = self._transcript
|
_transcript_ref = self._transcript
|
||||||
_doc_context_ref = self._document_context
|
_doc_context_ref = self._document_context
|
||||||
@@ -1042,7 +1078,7 @@ class VoiceSession:
|
|||||||
instructions += f"\n\nAktive Confluence-Seite: {_active_conf_id}. Du brauchst den Nutzer NICHT nach der page_id zu fragen — nutze automatisch diese ID fuer read_confluence_page und update_confluence_page."
|
instructions += f"\n\nAktive Confluence-Seite: {_active_conf_id}. Du brauchst den Nutzer NICHT nach der page_id zu fragen — nutze automatisch diese ID fuer read_confluence_page und update_confluence_page."
|
||||||
agent = _NoiseFilterAgent(
|
agent = _NoiseFilterAgent(
|
||||||
instructions=instructions,
|
instructions=instructions,
|
||||||
tools=[search_web, set_user_timezone, search_confluence, read_confluence_page, update_confluence_page, think_deeper, look_at_screen],
|
tools=[search_web, set_user_timezone, search_confluence, read_confluence_page, update_confluence_page, create_confluence_page, think_deeper, look_at_screen],
|
||||||
)
|
)
|
||||||
io_opts = room_io.RoomOptions(
|
io_opts = room_io.RoomOptions(
|
||||||
participant_identity=remote_identity,
|
participant_identity=remote_identity,
|
||||||
|
|||||||
Reference in New Issue
Block a user