From 10762a53da53862be68b4803c794e9ee5d6a4163 Mon Sep 17 00:00:00 2001 From: Christian Gick Date: Fri, 27 Feb 2026 08:04:01 +0200 Subject: [PATCH] feat(MAT-57): Add Confluence write & create tools to voice and text chat MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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

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 --- bot.py | 112 ++++++++++++++++++++++++++++++++++++++++++++++ confluence-collab | 2 +- voice.py | 44 ++++++++++++++++-- 3 files changed, 153 insertions(+), 5 deletions(-) diff --git a/bot.py b/bot.py index edc3048..daa5b16 100644 --- a/bot.py +++ b/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", "function": { @@ -476,6 +508,80 @@ class AtlassianClient: except Exception as 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}

" for p in paragraphs) if paragraphs else f"

{new_content}

" + 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}

" for p in paragraphs) if paragraphs else f"

{content}

" + 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: cloud_id = await self._get_cloud_id(token) if not cloud_id: @@ -1909,6 +2015,12 @@ class Bot: 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 == "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": return await self.atlassian.jira_search(token, args["jql"], args.get("limit", 10)) elif tool_name == "jira_get_issue": diff --git a/confluence-collab b/confluence-collab index a189fa3..7e85918 160000 --- a/confluence-collab +++ b/confluence-collab @@ -1 +1 @@ -Subproject commit a189fa326b68e20ec2cdf80c7a2157f6f4b0645f +Subproject commit 7e85918233b0d7268a254876784d9d981abd3c52 diff --git a/voice.py b/voice.py index 6f94414..7cc9c3d 100644 --- a/voice.py +++ b/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. - 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. -- 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.""" @@ -334,6 +334,22 @@ async def _confluence_update_section(page_id: str, section_heading: str, new_htm 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: """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 change, update, or rewrite part of a document. - section_heading: heading text of the section to update - - new_content: new plain text for the section (will be wrapped in

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 Human sees changes instantly in their browser via Live Docs.""" 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." logger.info("CONFLUENCE_UPDATE: page=%s section='%s'", pid, section_heading) try: - new_html = f"

{new_content}

" + # Wrap each paragraph in

tags for proper formatting + paragraphs = [p.strip() for p in new_content.split("\n") if p.strip()] + new_html = "".join(f"

{p}

" for p in paragraphs) if paragraphs else f"

{new_content}

" result = await _confluence_update_section(pid, section_heading, new_html) logger.info("CONFLUENCE_UPDATE_OK: %s", result) return result @@ -884,6 +902,24 @@ class VoiceSession: logger.warning("CONFLUENCE_UPDATE_FAIL: %s", 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}

" for p in paragraphs) if paragraphs else f"

{content}

" + 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 _transcript_ref = self._transcript _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." agent = _NoiseFilterAgent( 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( participant_identity=remote_identity,