From d985f9a593359eae230dea947351172688ebd977 Mon Sep 17 00:00:00 2001 From: Christian Gick Date: Wed, 18 Mar 2026 18:19:00 +0200 Subject: [PATCH] fix: convert markdown to HTML in approval messages Matrix needs formatted_body as HTML, not raw markdown. Added _md_to_html for bold/italic/code conversion. Co-Authored-By: Claude Opus 4.6 (1M context) --- .DS_Store | Bin 0 -> 6148 bytes CLAUDE.md.migrated.20260308 | 37 +++++++ pipelines/engine.py | 22 +++- test_element_call.py | 199 ++++++++++++++++++++++++++++++++++++ 4 files changed, 255 insertions(+), 3 deletions(-) create mode 100644 .DS_Store create mode 100644 CLAUDE.md.migrated.20260308 create mode 100644 test_element_call.py diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 0000000000000000000000000000000000000000..4ee6927048148d33a6dcf4508bfc44955c2f96cb GIT binary patch literal 6148 zcmeHKy-EW?5S}$x1OXzF(OX0}#dw_Cge_PP zla+qPfEsi}1?r<069q(pzpem(yDPLsb&BYavfpnXUvZlf>O`$#rJlrHlS*!5HX}~L zuofjX%*?mnAKscb_j~iO8b4rlkF(KTVHxs}PYbc1IFT2I2idV~jF;PY<>y-+f*vb^rs5sX=&P%20uZ zs_YR%89MyFoflY44H`Nry)vF-S623fVk#Bzz3F_wxR$4 literal 0 HcmV?d00001 diff --git a/CLAUDE.md.migrated.20260308 b/CLAUDE.md.migrated.20260308 new file mode 100644 index 0000000..7c0a8e6 --- /dev/null +++ b/CLAUDE.md.migrated.20260308 @@ -0,0 +1,37 @@ +# Matrix AI Agent + +Matrix bot with memory, voice, RAG, and Confluence collaboration. + +## Deployment +- **VM:** matrix.agiliton.internal +- **Path:** /opt/matrix-ai-agent/ +- **Deploy:** `agiliton-deploy matrix-ai-agent` +- **Jira project:** MAT + +## Architecture +- `bot.py` — Main Matrix bot (131KB, E2EE, memory-aware) +- `voice.py` — LiveKit voice integration +- `agent.py` — Pipecat voice agent +- `memory-service/` — FastAPI service for encrypted memory storage (pgvector) +- `confluence-collab/` — Confluence collaboration MCP server +- `article_summary/` — Article summarization + TTS + +## Docker Services +- `bot` — Main bot process +- `agent` — Voice agent (host networking) +- `memory-service` — Memory API (port 8090, connects as `memory_app` with RLS) +- `memory-db` — pgvector/pg17 with SSL + Row-Level Security + +## Memory Encryption (MAT-107) +- Per-user Fernet encryption: `HMAC-SHA256(master_key, user_id)` key derivation +- Encrypted fields: `fact`, `chunk_text`, `summary` +- Embeddings unencrypted (required for vector search) +- RLS policies enforce user isolation at DB level +- `memory_app` role for queries, `memory` owner for DDL/health +- SSL between memory-service and memory-db + +## Secrets +All in `.env` (gitignored). Key vars: +- `MEMORY_ENCRYPTION_KEY` — Master key for memory encryption +- `MEMORY_APP_PASSWORD` — Restricted DB role password +- `MATRIX_BOT_PASSWORD`, `LITELLM_API_KEY`, `ELEVENLABS_API_KEY`, etc. diff --git a/pipelines/engine.py b/pipelines/engine.py index 06833d8..6bfddc4 100644 --- a/pipelines/engine.py +++ b/pipelines/engine.py @@ -217,12 +217,28 @@ class PipelineEngine: error=str(exc), ) + @staticmethod + def _md_to_html(text: str) -> str: + """Convert basic markdown to HTML for Matrix formatted_body.""" + import re as _re + html = text + # Bold: **text** -> text + html = _re.sub(r'\*\*(.+?)\*\*', r'\1', html) + # Italic: *text* -> text + html = _re.sub(r'(?\1', html) + # Code: `text` -> text + html = _re.sub(r'`(.+?)`', r'\1', html) + # Newlines + html = html.replace("\n", "
") + return html + async def _execute_approval_step( self, step: dict, target_room: str, execution_id: str, timeout_s: int ) -> str: """Post approval message and wait for reaction.""" message = step.get("message", "Approve this action?") - formatted_msg = f"**Approval Required**\n\n{message}\n\nReact with \U0001f44d to approve or \U0001f44e to decline." + body = f"**Approval Required**\n\n{message}\n\nReact with \U0001f44d to approve or \U0001f44e to decline." + html = self._md_to_html(body) # Send message and get event ID resp = await self.matrix_client.room_send( @@ -230,9 +246,9 @@ class PipelineEngine: message_type="m.room.message", content={ "msgtype": "m.text", - "body": formatted_msg, + "body": body, "format": "org.matrix.custom.html", - "formatted_body": formatted_msg.replace("\n", "
"), + "formatted_body": html, }, ) event_id = resp.event_id if hasattr(resp, "event_id") else None diff --git a/test_element_call.py b/test_element_call.py new file mode 100644 index 0000000..9251ed7 --- /dev/null +++ b/test_element_call.py @@ -0,0 +1,199 @@ +"""Playwright test: Element Call with matrix-ai-agent bot. + +Usage: + python3 test_element_call.py [--headless] [--no-e2ee-check] + +Logs in as testbot-playwright, creates DM with bot, starts Element Call, +uses fake microphone audio, monitors bot logs for VAD/speech events. +""" +import asyncio +import argparse +import subprocess +import sys +import time +from playwright.async_api import async_playwright + +# Test config +ELEMENT_URL = "https://element.agiliton.eu" +TEST_USER = "@testbot-playwright:agiliton.eu" +TEST_USER_LOCAL = "testbot-playwright" +TEST_PASSWORD = "TestP@ssw0rd-1771760269" +BOT_USER = "@ai:agiliton.eu" +HOMESERVER = "https://matrix.agiliton.eu" + + +async def wait_for_bot_event(keyword: str, timeout: int = 60) -> bool: + """Poll bot container logs for a specific keyword.""" + deadline = time.time() + timeout + while time.time() < deadline: + result = subprocess.run( + ["ssh", "root@matrix.agiliton.internal", + "cd /opt/matrix-ai-agent && docker compose logs bot --tail=50 2>&1"], + capture_output=True, text=True, timeout=15 + ) + if keyword in result.stdout: + return True + await asyncio.sleep(2) + return False + + +async def run_test(headless: bool = True): + async with async_playwright() as p: + # Launch with fake audio device so VAD can trigger + browser = await p.chromium.launch( + headless=headless, + args=[ + "--use-fake-ui-for-media-stream", + "--use-fake-device-for-media-stream", + "--allow-running-insecure-content", + "--disable-web-security", + "--no-sandbox", + ] + ) + context = await browser.new_context( + permissions=["microphone", "camera"], + # Grant media permissions automatically + ) + page = await context.new_page() + + # Capture console logs + page.on("console", lambda msg: print(f" [browser] {msg.type}: {msg.text}") if msg.type in ("error", "warn") else None) + + print(f"[1] Navigating to {ELEMENT_URL}...") + await page.goto(ELEMENT_URL, wait_until="networkidle", timeout=30000) + await page.screenshot(path="/tmp/element-01-loaded.png") + + # Handle "Continue" button if shown (welcome screen) + try: + await page.click("text=Continue", timeout=3000) + except Exception: + pass + + print("[2] Logging in...") + # Click Sign In button if present + try: + await page.click("text=Sign in", timeout=5000) + except Exception: + pass + + # Wait for username field + await page.wait_for_selector("input[type='text'], input[id='mx_LoginForm_username']", timeout=15000) + await page.screenshot(path="/tmp/element-02-login.png") + + # Fill username + username_input = page.locator("input[type='text'], input[id='mx_LoginForm_username']").first + await username_input.fill(TEST_USER_LOCAL) + + # Fill password + password_input = page.locator("input[type='password']").first + await password_input.fill(TEST_PASSWORD) + + # Submit + await page.keyboard.press("Enter") + await page.wait_for_timeout(5000) + await page.screenshot(path="/tmp/element-03-after-login.png") + + # Handle "Use without" / skip verification prompts + for skip_text in ["Use without", "Skip", "I'll verify later", "Continue"]: + try: + await page.click(f"text={skip_text}", timeout=2000) + await page.wait_for_timeout(1000) + except Exception: + pass + + await page.screenshot(path="/tmp/element-04-home.png") + + print("[3] Creating DM with bot...") + # Click new DM button + try: + # Try the compose / start DM button + await page.click("[aria-label='Start chat'], [title='Start chat'], button:has-text('Start')", timeout=5000) + except Exception: + # Try the + button near People + try: + await page.click("[aria-label='Add room'], .mx_RoomList_headerButtons button", timeout=5000) + except Exception: + print(" Could not find DM button, trying navigation...") + await page.goto(f"{ELEMENT_URL}/#/new", timeout=10000) + + await page.wait_for_timeout(2000) + await page.screenshot(path="/tmp/element-05-dm-dialog.png") + + # Search for bot user + try: + dm_input = page.locator("input[type='text']").first + await dm_input.fill(BOT_USER) + await page.wait_for_timeout(2000) + # Click on result + await page.click(f"text={BOT_USER}", timeout=5000) + await page.wait_for_timeout(1000) + # Confirm DM + await page.click("button:has-text('Go'), button:has-text('OK'), button:has-text('Direct Message')", timeout=5000) + except Exception as e: + print(f" DM creation error: {e}") + + await page.wait_for_timeout(3000) + await page.screenshot(path="/tmp/element-06-room.png") + + print("[4] Looking for call button...") + # Look for the video call button in the room header + try: + await page.click("[aria-label='Video call'], [title='Video call'], button.mx_LegacyCallButton", timeout=10000) + print(" Clicked video call button") + except Exception as e: + print(f" Could not find call button: {e}") + # Try text-based + try: + await page.click("text=Video call", timeout=5000) + except Exception: + pass + + await page.wait_for_timeout(5000) + await page.screenshot(path="/tmp/element-07-call-started.png") + + print("[5] Waiting for bot to join (60s)...") + # Monitor bot logs for connection + bot_joined = await wait_for_bot_event("Connected", timeout=60) + if bot_joined: + print(" ✓ Bot joined the call!") + else: + print(" ✗ Bot did not join within 60s") + + print("[6] Fake microphone is active — waiting for VAD events (30s)...") + await page.wait_for_timeout(10000) # let call run for 10s + await page.screenshot(path="/tmp/element-08-in-call.png") + + vad_triggered = await wait_for_bot_event("VAD: user_state=", timeout=20) + if vad_triggered: + print(" ✓ VAD triggered! Audio pipeline works, E2EE decryption successful.") + else: + print(" ✗ VAD did not trigger — either E2EE blocks audio or pipeline issue") + + speech_transcribed = await wait_for_bot_event("USER_SPEECH:", timeout=30) + if speech_transcribed: + print(" ✓ Speech transcribed! Full pipeline working.") + else: + print(" ✗ No speech transcription") + + print("[7] Checking E2EE state in logs...") + result = subprocess.run( + ["ssh", "root@matrix.agiliton.internal", + "cd /opt/matrix-ai-agent && docker compose logs bot --tail=100 2>&1"], + capture_output=True, text=True, timeout=15 + ) + for line in result.stdout.split("\n"): + if any(kw in line for kw in ["E2EE_STATE", "VAD", "USER_SPEECH", "AGENT_SPEECH", "DEC_FAILED", "MISSING_KEY", "shared_key", "HKDF"]): + print(f" LOG: {line.strip()}") + + await page.wait_for_timeout(5000) + await page.screenshot(path="/tmp/element-09-final.png") + + print("\nScreenshots saved to /tmp/element-*.png") + await browser.close() + + +if __name__ == "__main__": + parser = argparse.ArgumentParser() + parser.add_argument("--headless", action="store_true", help="Run headless") + args = parser.parse_args() + asyncio.run(run_test(headless=args.headless))