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:
Christian Gick
2026-02-27 08:04:01 +02:00
parent 9833c89aa6
commit 10762a53da
3 changed files with 153 additions and 5 deletions

112
bot.py
View File

@@ -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":

View File

@@ -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,