
Matrix Communication
- 38 installs
- 4 repo stars
- Updated August 2, 2026
- netresearch/matrix-skill
Helps with ai & agent building tasks.
About
matrix-communication is a Claude Code skill for ai & agent building. It helps solo builders move faster with AI-assisted coding.
- matrix-communication
- AI & Agent Building
- AI-coding skill
Matrix Communication by the numbers
- 38 all-time installs (skills.sh)
- +1 installs in the week ending Aug 4, 2026 (Skillselion tracking)
- Ranked #8,450 of 16,546 AI & Agent Building skills by installs in the Skillselion catalog
- Data as of Aug 4, 2026 (Skillselion catalog sync)
npx skills add https://github.com/netresearch/matrix-skill --skill matrix-communicationAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 38 |
|---|---|
| repo stars | ★ 4 |
| Last updated | August 2, 2026 |
| Repository | netresearch/matrix-skill ↗ |
What it does
Helps with ai & agent building tasks.
Files
Matrix Communication
Matrix rooms: send, read, download media. *Always use `-e2ee.py` scripts.**
Bash `!` rule: Prepend set +H && when arguments contain !
Quick Reference
ROOM: name (test), ID (!abc:server), or alias (#room:server).
# Send (E2EE)
set +H && uv run ${CLAUDE_SKILL_DIR}/scripts/matrix-send-e2ee.py ROOM "message"
uv run ${CLAUDE_SKILL_DIR}/scripts/matrix-send-e2ee.py ROOM "message" --no-prefix
uv run ${CLAUDE_SKILL_DIR}/scripts/matrix-send-e2ee.py ROOM "is deploying" --emote
uv run ${CLAUDE_SKILL_DIR}/scripts/matrix-send-e2ee.py ROOM "📦 Release: …" --notice # unattended automation; no auto-reply loops
uv run ${CLAUDE_SKILL_DIR}/scripts/matrix-send-e2ee.py ROOM "reply" --thread '$rootEventId'
uv run ${CLAUDE_SKILL_DIR}/scripts/matrix-send-e2ee.py ROOM "reply" --reply '$eventId'
# Read (E2EE) — JSON includes media URL/info for m.image/m.file/m.video/m.audio
uv run ${CLAUDE_SKILL_DIR}/scripts/matrix-read-e2ee.py ROOM --limit 10
uv run ${CLAUDE_SKILL_DIR}/scripts/matrix-read-e2ee.py ROOM --limit 20 --json
# Download media (E2EE) — decrypts and saves by event ID
uv run ${CLAUDE_SKILL_DIR}/scripts/matrix-download-e2ee.py ROOM '$eventId' --output /tmp
# Edit / Delete / React
uv run ${CLAUDE_SKILL_DIR}/scripts/matrix-edit-e2ee.py ROOM '$eventId' "new text"
uv run ${CLAUDE_SKILL_DIR}/scripts/matrix-redact.py ROOM '$eventId' "reason"
uv run ${CLAUDE_SKILL_DIR}/scripts/matrix-react.py ROOM '$eventId' "✅"
# Rooms
uv run ${CLAUDE_SKILL_DIR}/scripts/matrix-rooms.py
uv run ${CLAUDE_SKILL_DIR}/scripts/matrix-rooms.py --search ops
uv run ${CLAUDE_SKILL_DIR}/scripts/matrix-resolve.py "#room:server"
# E2EE management
uv run ${CLAUDE_SKILL_DIR}/scripts/matrix-e2ee-setup.py --status
MATRIX_PASSWORD="pass" uv run ${CLAUDE_SKILL_DIR}/scripts/matrix-e2ee-setup.py
uv run ${CLAUDE_SKILL_DIR}/scripts/matrix-e2ee-verify.py --timeout 180
uv run ${CLAUDE_SKILL_DIR}/scripts/matrix-fetch-keys.py ROOM --sync-time 60
uv run ${CLAUDE_SKILL_DIR}/scripts/matrix-key-backup.py --recovery-key "EsTj ..." --import-keys
# Health check (uses python3, not uv run)
python3 ${CLAUDE_SKILL_DIR}/scripts/matrix-doctor.py --installScript Selection
| Operation | E2EE (preferred) | Non-E2EE Fallback |
|---|---|---|
| Send | matrix-send-e2ee.py | matrix-send.py |
| Read | matrix-read-e2ee.py | matrix-read.py |
| Edit | matrix-edit-e2ee.py | matrix-edit.py |
| Download | matrix-download-e2ee.py | — |
| React | matrix-react.py | (same) |
| Delete | matrix-redact.py | (same) |
Other: matrix-rooms.py, matrix-resolve.py, matrix-e2ee-setup.py, matrix-e2ee-verify.py, matrix-fetch-keys.py, matrix-key-backup.py, matrix-doctor.py.
Config
~/.config/matrix/config.json — required: homeserver, user_id. Optional: access_token
Error Handling
| Error | Solution |
|---|---|
M_FORBIDDEN | Join room first in Element |
M_UNKNOWN_TOKEN | Get new token from Element |
M_LIMIT_EXCEEDED | Wait and retry |
Could not find room | matrix-rooms.py to list rooms |
[Unable to decrypt] | First: uv run ${CLAUDE_SKILL_DIR}/scripts/matrix-fetch-keys.py ROOM --sync-time 60 (requests keys from other devices, no recovery key needed); fallback: uv run ${CLAUDE_SKILL_DIR}/scripts/matrix-key-backup.py --recovery-key "..." --import-keys |
libolm not found | Linux: apt install libolm-dev; macOS 26+ unsupported (see references/setup-guide.md) |
matrix-nio not found | python3 ${CLAUDE_SKILL_DIR}/scripts/matrix-doctor.py --install |
Invalid password | Use env var: MATRIX_PASSWORD="pass" uv run ... |
signature failed | Dedicated device via matrix-e2ee-setup.py |
Common Mistakes
- Using non-E2EE scripts for encrypted rooms — always use
*-e2ee.py - Forgetting `set +H` —
!in messages gets mangled by bash - Skipping `--import-keys` — key backup doesn't save without it
- Using Element X for verification — use Element Desktop or Android
- Hardcoding passwords — use
MATRIX_PASSWORDenv var
References
references/setup-guide.md— setupreferences/e2ee-guide.md— E2EE, key recovery, verificationreferences/messaging-guide.md— formatting, reactionsreferences/api-reference.md— Matrix API- netresearch/matrix-skill
{
"skill_name": "matrix-communication",
"evals": [
{
"id": 1,
"eval_name": "send-basic-message",
"prompt": "Send a message to the dev room on Matrix saying 'Deployment complete for v2.3.0'.",
"expected_output": "Uses matrix-send-e2ee.py with room name and message text.",
"files": [],
"assertions": [
"Uses matrix-send-e2ee.py (E2EE preferred over non-E2EE)",
"Uses uv run to execute the script",
"Passes 'dev' as room identifier",
"Passes message text as second argument"
]
},
{
"id": 2,
"eval_name": "send-with-exclamation",
"prompt": "Send 'Deploy complete! All tests passed!' to the ops room.",
"expected_output": "Handles bash history expansion by using set +H before the command.",
"files": [],
"assertions": [
"Uses matrix-send-e2ee.py (E2EE preferred)",
"Prepends set +H && before the command",
"Does not let bash history expansion corrupt the message"
]
},
{
"id": 3,
"eval_name": "send-emote",
"prompt": "Send an emote message to the ops room saying 'is deploying to production'.",
"expected_output": "Uses matrix-send-e2ee.py with --emote flag.",
"files": [],
"assertions": [
"Uses matrix-send-e2ee.py (E2EE preferred)",
"Uses --emote flag",
"Passes 'ops' as room identifier",
"Passes 'is deploying to production' as message"
]
},
{
"id": 4,
"eval_name": "send-no-prefix",
"prompt": "Send a plain message to the team room without the bot prefix: 'Meeting moved to 3pm'.",
"expected_output": "Uses matrix-send-e2ee.py with --no-prefix flag.",
"files": [],
"assertions": [
"Uses matrix-send-e2ee.py (E2EE preferred)",
"Uses --no-prefix flag",
"Passes room identifier and message"
]
},
{
"id": 5,
"eval_name": "send-to-room-alias",
"prompt": "Send 'Hello everyone' to the room #team:matrix.org.",
"expected_output": "Uses matrix-send-e2ee.py with full room alias as ROOM argument.",
"files": [],
"assertions": [
"Uses matrix-send-e2ee.py (E2EE preferred)",
"Passes '#team:matrix.org' as room argument",
"Passes 'Hello everyone' as message"
]
},
{
"id": 6,
"eval_name": "send-formatted-message",
"prompt": "Send a deployment notification to the ops room: bold 'Deployed' header, version 1.5.0, with a Jira link to PROJ-456.",
"expected_output": "Uses matrix-send-e2ee.py with markdown-formatted message.",
"files": [],
"assertions": [
"Uses matrix-send-e2ee.py (E2EE preferred)",
"Uses markdown bold for 'Deployed'",
"Includes version information",
"Includes Jira URL"
]
},
{
"id": 7,
"eval_name": "thread-reply",
"prompt": "Reply in the thread started by event $root456 in the dev room with 'Tests passing, ready for review'.",
"expected_output": "Uses matrix-send-e2ee.py with --thread flag and root event ID.",
"files": [],
"assertions": [
"Uses matrix-send-e2ee.py (E2EE preferred)",
"Uses --thread flag with '$root456'",
"Passes 'dev' as room identifier",
"Passes message text as argument"
]
},
{
"id": 8,
"eval_name": "direct-reply",
"prompt": "Reply to the message $msg123 in the dev room saying 'Agreed, let's proceed with that approach'.",
"expected_output": "Uses matrix-send-e2ee.py with --reply flag.",
"files": [],
"assertions": [
"Uses matrix-send-e2ee.py (E2EE preferred)",
"Uses --reply flag (not --thread) with '$msg123'",
"Passes 'dev' as room identifier"
]
},
{
"id": 9,
"eval_name": "read-recent-messages",
"prompt": "Read the last 5 messages from the agent-work Matrix room.",
"expected_output": "Uses matrix-read-e2ee.py with room name and --limit flag.",
"files": [],
"assertions": [
"Uses matrix-read-e2ee.py (E2EE preferred)",
"Passes 'agent-work' as room identifier",
"Uses --limit 5 flag"
]
},
{
"id": 10,
"eval_name": "read-messages-json",
"prompt": "Read the last 20 messages from the ops room as JSON so I can analyze them.",
"expected_output": "Uses matrix-read-e2ee.py with --json and --limit flags.",
"files": [],
"assertions": [
"Uses matrix-read-e2ee.py (E2EE preferred)",
"Uses --json flag for structured output",
"Uses --limit 20 flag"
]
},
{
"id": 11,
"eval_name": "read-with-request-keys",
"prompt": "Read the last 10 messages from the dev room and try to recover any undecryptable messages.",
"expected_output": "Uses matrix-read-e2ee.py with --request-keys flag.",
"files": [],
"assertions": [
"Uses matrix-read-e2ee.py with --request-keys flag",
"Uses --limit 10 flag"
]
},
{
"id": 12,
"eval_name": "edit-message",
"prompt": "Edit the message $evt789 in the general room to say 'Corrected: deploy at 3pm not 2pm'.",
"expected_output": "Uses matrix-edit-e2ee.py with room, event ID, and new text.",
"files": [],
"assertions": [
"Uses matrix-edit-e2ee.py (E2EE preferred over matrix-edit.py)",
"Passes room identifier as first argument",
"Passes event ID '$evt789' as second argument",
"Passes corrected text as third argument"
]
},
{
"id": 13,
"eval_name": "delete-message",
"prompt": "Delete the message $evt999 from the dev room because it was sent by mistake.",
"expected_output": "Uses matrix-redact.py with room, event ID, and reason.",
"files": [],
"assertions": [
"Uses matrix-redact.py script",
"Passes room identifier",
"Passes event ID '$evt999'",
"Optionally passes a reason string"
]
},
{
"id": 14,
"eval_name": "react-to-message",
"prompt": "React to the message with event ID $abc123 in the ops room with a checkmark emoji.",
"expected_output": "Uses matrix-react.py with room name, event ID, and emoji.",
"files": [],
"assertions": [
"Uses matrix-react.py script",
"Passes 'ops' as room identifier",
"Passes '$abc123' as event ID",
"Passes checkmark emoji as reaction"
]
},
{
"id": 15,
"eval_name": "list-rooms",
"prompt": "List my Matrix rooms so I can see which ones I'm joined to.",
"expected_output": "Uses matrix-rooms.py to list joined rooms.",
"files": [],
"assertions": [
"Uses matrix-rooms.py script",
"Uses uv run to execute",
"Does not call Matrix API directly"
]
},
{
"id": 16,
"eval_name": "search-rooms",
"prompt": "Search my Matrix rooms for any room with 'ops' in the name.",
"expected_output": "Uses matrix-rooms.py with --search flag.",
"files": [],
"assertions": [
"Uses matrix-rooms.py with --search flag",
"Passes 'ops' as search term"
]
},
{
"id": 17,
"eval_name": "resolve-room-alias",
"prompt": "What is the room ID for #general:matrix.example.com?",
"expected_output": "Uses matrix-resolve.py to resolve the alias.",
"files": [],
"assertions": [
"Uses matrix-resolve.py script",
"Passes '#general:matrix.example.com' as argument"
]
},
{
"id": 18,
"eval_name": "health-check",
"prompt": "Check if my Matrix skill dependencies are properly installed.",
"expected_output": "Uses matrix-doctor.py with python3 (not uv run).",
"files": [],
"assertions": [
"Uses matrix-doctor.py script",
"Uses python3 (not uv run) to execute",
"Optionally uses --install flag"
]
},
{
"id": 19,
"eval_name": "e2ee-setup-status",
"prompt": "Check if my E2EE device is properly set up for Matrix.",
"expected_output": "Uses matrix-e2ee-setup.py with --status flag.",
"files": [],
"assertions": [
"Uses matrix-e2ee-setup.py with --status flag",
"Uses uv run to execute",
"Does not create a new device (only checks status)"
]
},
{
"id": 20,
"eval_name": "e2ee-setup-new",
"prompt": "I need to set up Matrix E2EE for the first time. My password has special characters.",
"expected_output": "Uses MATRIX_PASSWORD env var with matrix-e2ee-setup.py.",
"files": [],
"assertions": [
"Uses matrix-e2ee-setup.py script",
"Uses MATRIX_PASSWORD environment variable (not command-line arg)",
"References setup-guide.md or explains env var avoids shell escaping"
]
},
{
"id": 21,
"eval_name": "device-verification",
"prompt": "I need to verify my Matrix E2EE device with Element Desktop.",
"expected_output": "Uses matrix-e2ee-verify.py with --timeout flag.",
"files": [],
"assertions": [
"Uses matrix-e2ee-verify.py script",
"Uses --timeout flag with reasonable value",
"Mentions Element Desktop (not Element X)",
"References e2ee-guide.md for details"
]
},
{
"id": 22,
"eval_name": "restore-key-backup",
"prompt": "I have a recovery key 'EsTj qRGp YB4C abcd'. Restore my key backup so I can read old messages.",
"expected_output": "Uses matrix-key-backup.py with --recovery-key and --import-keys.",
"files": [],
"assertions": [
"Uses matrix-key-backup.py script",
"Uses --recovery-key flag with the provided key",
"Uses --import-keys flag (must not forget this)",
"Does not omit --import-keys"
]
},
{
"id": 23,
"eval_name": "fetch-missing-keys",
"prompt": "I'm seeing '[Unable to decrypt]' for some messages in the IT room. Can you try fetching the missing keys?",
"expected_output": "Uses matrix-fetch-keys.py to request keys from other devices.",
"files": [],
"assertions": [
"Uses matrix-fetch-keys.py script",
"Passes 'IT' as room identifier",
"Uses --sync-time flag with reasonable timeout"
]
},
{
"id": 24,
"eval_name": "error-recovery-forbidden",
"prompt": "I tried to send a message to the ops room but got M_FORBIDDEN. What should I do?",
"expected_output": "Advises joining the room in Element first.",
"files": [],
"assertions": [
"Suggests joining the room in Element first",
"Does not retry the same command blindly",
"References error handling guidance"
]
},
{
"id": 25,
"eval_name": "error-recovery-decrypt",
"prompt": "I'm reading messages in the dev room and most show '[Unable to decrypt]'. How do I fix this?",
"expected_output": "Suggests key backup restore or key fetching, not non-E2EE fallback.",
"files": [],
"assertions": [
"Suggests matrix-key-backup.py with --recovery-key and --import-keys, or matrix-fetch-keys.py",
"Does not suggest non-E2EE fallback as primary solution"
]
}
]
}
Matrix Communication
Send and receive messages in Matrix chat rooms with full E2EE encryption support.
Features
- E2EE Encryption - Full end-to-end encryption support
- Send Messages - Post to any joined room with markdown formatting
- Read Messages - Decrypt and read encrypted messages
- Edit Messages - Modify existing messages
- Reactions - Add emoji reactions (✅ 👍 🚀)
- Redact - Delete messages
- Bot Prefix - Optional 🤖 prefix for automated messages
Installation
Option 1: Via Netresearch Marketplace (Recommended)
/plugin marketplace add netresearch/claude-code-marketplaceThen install with /install-plugin netresearch/matrix-skill
Option 2: Download Release
Download the latest release and extract to ~/.claude/skills/matrix-communication/
Usage
The skill triggers automatically on:
- Room references:
#room:server,!roomid:server - Chat requests: "send to matrix", "post in chat"
- Matrix URLs:
https://matrix.*/,https://element.*/
Setup
Just ask:
"Set up the Matrix skill for me"
The agent guides you through homeserver, user ID, and E2EE device creation.
Example Prompts
"Send 'Deployment complete!' to #ops:matrix.org"
"Read the last 10 messages from #dev:matrix.org"
"React with ✅ to the last message in #support"Structure
matrix-communication/
├── SKILL.md # AI instructions
├── README.md # This file
├── scripts/ # All Matrix scripts
│ ├── matrix-send-e2ee.py
│ ├── matrix-read-e2ee.py
│ ├── matrix-edit-e2ee.py
│ └── ...
└── references/
└── api-reference.mdReferences
references/api-reference.md- Matrix API endpoints
License
MIT License - See LICENSE for details.
Credits
Developed and maintained by Netresearch DTT GmbH.
---
Made with ❤️ for Open Source by [Netresearch](https://www.netresearch.de/)
Matrix Client-Server API Reference
Quick reference for Matrix API endpoints used by this skill.
Authentication
All requests require Bearer token authentication:
curl -H "Authorization: Bearer $MATRIX_TOKEN" ...Base URL
https://matrix.org/_matrix/client/v3Endpoints
Account
# Who am I?
GET /account/whoami
# Response:
{
"user_id": "@user:matrix.org",
"device_id": "ABCDEF",
"is_guest": false
}Rooms
# List joined rooms
GET /joined_rooms
# Response:
{
"joined_rooms": ["!room1:server", "!room2:server"]
}
# Resolve room alias to ID
GET /directory/room/%23alias:server
# Response:
{
"room_id": "!abc:server",
"servers": ["server"]
}
# Get room name
GET /rooms/{roomId}/state/m.room.name
# Response:
{
"name": "Room Name"
}Messages
# Send message
PUT /rooms/{roomId}/send/m.room.message/{txnId}
# Body (plain text):
{
"msgtype": "m.text",
"body": "Hello!"
}
# Body (formatted):
{
"msgtype": "m.text",
"body": "**Hello!**",
"format": "org.matrix.custom.html",
"formatted_body": "<strong>Hello!</strong>"
}
# Response:
{
"event_id": "$abc123"
}
# Read messages (via sync)
GET /sync?timeout=0&full_state=true&filter={...}
# Filter for specific room:
{
"room": {
"rooms": ["!roomId:server"],
"timeline": {"limit": 10}
}
}Message Types
| msgtype | Description |
|---|---|
m.text | Plain text message |
m.notice | Bot/notification message |
m.emote | Action message (/me) |
m.image | Image attachment |
m.file | File attachment |
Reactions
# Send reaction
PUT /rooms/{roomId}/send/m.reaction/{txnId}
# Body:
{
"m.relates_to": {
"rel_type": "m.annotation",
"event_id": "$target_event_id",
"key": "👍"
}
}Account Data
# Get account data (e.g., key backup passphrase info)
GET /user/{userId}/account_data/{type}
# Types: m.megolm_backup.v1, m.secret_storage.default_key, m.secret_storage.key.{keyId}Key Backup
# Get current backup version
GET /room_keys/version
# Response:
{
"algorithm": "m.megolm_backup.v1.curve25519-aes-sha2",
"auth_data": { ... },
"count": 1234,
"etag": "...",
"version": "5"
}
# Get backed-up keys
GET /room_keys/keys?version={version}Event Types
| type | Description |
|---|---|
m.room.message | Regular message |
m.room.encrypted | E2EE encrypted message |
m.room.name | Room name state |
m.room.topic | Room topic state |
m.room.member | Membership event |
m.reaction | Reaction annotation (relates_to another event) |
Error Codes
| errcode | Description |
|---|---|
M_FORBIDDEN | Access denied (not in room, no permission) |
M_UNKNOWN_TOKEN | Invalid or expired access token |
M_NOT_FOUND | Room/resource not found |
M_LIMIT_EXCEEDED | Rate limited |
M_GUEST_ACCESS_FORBIDDEN | Guest access not allowed |
Rate Limits
Matrix homeservers typically enforce rate limits:
- ~10 messages per second per room
- ~100 requests per second per user
The skill scripts include basic error handling for rate limits.
References
E2EE (End-to-End Encryption) Guide
Detailed guide for Matrix E2EE setup, device verification, and key management.
Which Script to Use?
| Scenario | Script | Notes |
|---|---|---|
| Unencrypted room | matrix-send.py | Fast, no deps |
| E2EE room with "allow unverified" | matrix-send.py | Works but not encrypted |
| E2EE room, proper encryption | matrix-send-e2ee.py | Requires libolm + setup |
E2EE Setup
Use a dedicated device -- this avoids key sync conflicts with Element:
# One-time setup: create dedicated E2EE device
# Option 1: Environment variable (recommended - handles special chars)
MATRIX_PASSWORD="YOUR_PASSWORD" uv run skills/matrix-communication/scripts/matrix-e2ee-setup.py
# Option 2: Interactive prompt (secure - password not in history)
uv run skills/matrix-communication/scripts/matrix-e2ee-setup.py
# Now send encrypted messages
uv run skills/matrix-communication/scripts/matrix-send-e2ee.py '#room:server' 'Encrypted message'
# Check setup status
uv run skills/matrix-communication/scripts/matrix-e2ee-setup.py --statusWhy dedicated device?
- Clean key state, no conflicts with Element
- Proper cross-signing setup
- Avoids "signature verification failed" errors
Access token fallback (not recommended): Using access_token from config reuses Element's device, causing key sync issues and verification problems. Only use if password-based setup isn't possible.
E2EE Script Usage
# First run after setup syncs keys (~2-5s)
uv run skills/matrix-communication/scripts/matrix-send-e2ee.py '#encrypted-room:server' 'Secret message'
# Subsequent runs faster (uses cached keys)
uv run skills/matrix-communication/scripts/matrix-send-e2ee.py '#encrypted-room:server' 'Another message'Storage locations:
- Device credentials:
~/.local/share/matrix-skill/store/credentials.json - Encryption keys:
~/.local/share/matrix-skill/store/*.db
Device Verification
Device verification marks a device as trusted and enables automatic key sharing.
# Auto-find Element device and initiate verification
uv run skills/matrix-communication/scripts/matrix-e2ee-verify.py --timeout 180
# Target specific device
uv run skills/matrix-communication/scripts/matrix-e2ee-verify.py --request DEVICE_ID --timeout 180
# With debug output
uv run skills/matrix-communication/scripts/matrix-e2ee-verify.py --debug --timeout 180Smart device selection: Automatically prioritizes Element clients (Desktop/Android/iOS) over backup devices that can't respond interactively.
Agent Workflow for Real-Time Emoji Display
The verification script writes emojis to /tmp/matrix_verification_emojis.txt for agent polling.
Step 1: Clear emoji file and start verification in background
rm -f /tmp/matrix_verification_emojis.txt
uv run skills/matrix-communication/scripts/matrix-e2ee-verify.py --timeout 180 > /tmp/verify_log.txt 2>&1 &Step 2: Poll for emojis and show to user immediately
for i in {1..30}; do
if [ -f /tmp/matrix_verification_emojis.txt ]; then
cat /tmp/matrix_verification_emojis.txt
break
fi
sleep 1
doneStep 3: Tell user to confirm in Element
- "Compare these emojis with what Element shows"
- "Click 'They match' in Element to complete verification"
Step 4: Wait for verification to complete
grep -q "VERIFICATION SUCCESSFUL" /tmp/verify_log.txt && echo "Verified!"Why Verify?
- Removes "unverified device" warnings for other users
- Enables automatic room key sharing from other devices
- Required for some security-conscious rooms
Reading E2EE Messages
# Read recent encrypted messages
uv run skills/matrix-communication/scripts/matrix-read-e2ee.py '#room:server' --limit 10
# JSON output for programmatic analysis
uv run skills/matrix-communication/scripts/matrix-read-e2ee.py '#room:server' --jsonFirst run (~2-5s) — the client syncs keys with the server.
Understanding [Unable to decrypt]
Messages showing [Unable to decrypt] mean your device lacks the Megolm session keys for those messages. This is not permanent — keys can be recovered:
| Situation | Solution |
|---|---|
| Messages sent before device was created | Restore from server-side key backup |
| Messages from before verification | Verify device, then request key forwarding |
| No other devices online | Use key backup with recovery key/passphrase |
Decision tree: 1. Have you verified your device? → If no, verify first (see above) 2. Are other verified devices online? → Try matrix-fetch-keys.py (Method 1) 3. Do you have a recovery key/passphrase? → Try matrix-key-backup.py (Method 2)
Fetching Missing Keys
Method 1: Request from Other Devices
After device verification, other devices can forward keys automatically:
# Fetch keys for a specific room
uv run skills/matrix-communication/scripts/matrix-fetch-keys.py ROOM --sync-time 60
# Extended wait for more keys
uv run skills/matrix-communication/scripts/matrix-fetch-keys.py IT --limit 200 --sync-time 120Requirements: device must be verified, other verified devices must be online.
Method 2: Restore from Server Backup (Recommended for old messages)
The matrix-key-backup.py script handles the full workflow: SSSS decryption → backup key derivation → session key decryption → import into local store.
# Check backup status
uv run skills/matrix-communication/scripts/matrix-key-backup.py --status
# Restore using recovery key AND import into local store
uv run skills/matrix-communication/scripts/matrix-key-backup.py --recovery-key "EsTj qRGp YB4C ..." --import-keys
# Restore using passphrase AND import
uv run skills/matrix-communication/scripts/matrix-key-backup.py --passphrase "your recovery passphrase" --import-keysImportant: The --import-keys flag is required to actually import decrypted session keys into your local store. Without it, keys are only displayed but not saved.
Find your recovery key in Element: Settings → Security & Privacy → Secure Backup → "Show Recovery Key"
Note: matrix-nio does not natively support server-side key backup (see matrix-nio#218). The matrix-key-backup.py script implements this manually using the Matrix API directly.
Verification with --listen Mode
The verification script supports waiting for incoming verification requests:
# Listen for incoming verification requests (e.g., from Element)
uv run skills/matrix-communication/scripts/matrix-e2ee-verify.py --timeout 180
# The script will:
# 1. Sync with server
# 2. Auto-detect Element devices
# 3. Initiate or accept verification
# 4. Display emoji for comparison
# 5. Write emojis to /tmp/matrix_verification_emojis.txt for agent pollingElement X compatibility: Element X uses different verification flows that may not be fully compatible. Use Element Desktop or Element Android for verification.
Troubleshooting
| Error | Cause | Solution |
|---|---|---|
[Unable to decrypt] | Missing session keys | Restore from backup with --import-keys |
MAC verification failed | Wrong recovery key or passphrase | Verify recovery key from Element settings |
PkDecryption errors | libolm version mismatch | Update libolm: apt install libolm-dev |
| Script hangs silently | stdout buffering in non-interactive context | Fixed in scripts (line_buffering=True) |
| Verification times out | No compatible device responding | Use Element Desktop, not Element X |
signature verification failed | Reusing Element's device | Use dedicated device via matrix-e2ee-setup.py |
Limitations
- First sync: Initial run ~2-5s for key exchange; subsequent runs ~2-3s
- Device trust: Auto-trusts devices (TOFU model)
- Setup required: First use requires user's Matrix password (one-time only)
- Verification: Cross-signing/room-based verification not fully supported by matrix-nio
- Key backup: Requires recovery key or passphrase (found in Element settings)
- matrix-nio limitation: No native server-side key backup support —
matrix-key-backup.pyworks around this via direct API calls
Matrix Messaging Guide
Message formatting, reactions, visual effects, and common communication patterns.
Message Types
Regular Messages (m.text)
Default -- use for most communication.
Emote Messages (m.emote)
Like IRC /me -- displays as action. Use --emote flag.
# Appears as: "* username is deploying to production"
uv run skills/matrix-communication/scripts/matrix-send-e2ee.py "#ops:matrix.org" "is deploying to production" --emoteWhen to use: Status updates, actions, presence indicators.
Notice Messages (m.notice)
Bot-flagged. Clients render m.notice visually distinct (usually muted) and other bots are forbidden from auto-replying to it — this prevents bot-on-bot loops. Use --notice flag (mutually exclusive with --emote).
# Unattended automation: release announcement from CI
uv run skills/matrix-communication/scripts/matrix-send-e2ee.py "#releases:example.com" \
"📦 Release: jira-skill v3.12.0 — progressive-disclosure refactor" --noticeWhen to use: Release announcements, CI summaries, scheduled digests, alert pings — anything posted unattended without a human reviewing first. For agent-on-behalf-of-human posts where a reply would be welcome, leave it as the default m.text.
Thread Replies
Reply in a thread to keep discussions organized. Use --thread with root event ID.
uv run skills/matrix-communication/scripts/matrix-send-e2ee.py "#dev:matrix.org" "Update: tests passing" --thread '$rootEventId'When to use: Ongoing updates, multi-step processes, avoiding main room clutter.
Direct Replies
Reply to a specific message. Use --reply with event ID.
uv run skills/matrix-communication/scripts/matrix-send-e2ee.py "#team:matrix.org" "Agreed, let's proceed" --reply '$eventId'Reactions
Add emoji reactions to indicate status without new messages.
uv run skills/matrix-communication/scripts/matrix-react.py "#ops:matrix.org" '$eventId' "checkmark"
uv run skills/matrix-communication/scripts/matrix-react.py "#dev:matrix.org" '$eventId' "thumbs-up"Common Reaction Patterns
| Emoji | Meaning | Use Case |
|---|---|---|
| ✅ | Done/Complete | Mark task as finished |
| 👍 | Acknowledged | Confirm receipt |
| 👀 | Looking into it | Started investigating |
| 🚀 | Deployed/Shipped | Indicate release |
| ⏳ | In progress | Working on it |
| ❌ | Failed/Blocked | Indicate problem |
Workflow example: Send "Going to reboot server" then later add checkmark reaction when complete.
Visual Effects (Element Clients)
Include specific emoji to trigger visual effects in Element/SchildiChat:
| Emoji | Effect | Use Case |
|---|---|---|
| 🎉 / 🎊 | Confetti | Celebrations, milestones |
| 🎆 | Fireworks | Major achievements |
| ❄️ | Snowfall | Seasonal, cool features |
Note: Effects only show for Element/SchildiChat users. Other clients see the emoji normally.
Message Formatting
All formatting is automatic -- just use markdown syntax.
Basic Formatting
| Syntax | Result | When to Use |
|---|---|---|
**bold** | bold | Emphasis, headings, status |
*italic* | italic | Secondary emphasis |
` code ` | code | Commands, file names, variables |
~~strike~~ | ~~strike~~ | Corrections, outdated info |
[text](url) | linked text | Custom link labels |
Matrix-Specific Features
| Syntax | Result | When to Use |
|---|---|---|
@user:server | Clickable mention | Notify specific users |
#room:server | Clickable room link | Reference other rooms |
> quote | Blockquote | Quote previous messages |
| `\ | \ | spoiler\ |
`lang ` | Code block | Multi-line code with highlighting |
Smart Link Shortening
URLs are automatically shortened:
| URL | Displayed As |
|---|---|
https://jira.*/browse/PROJ-123 | PROJ-123 |
https://github.com/owner/repo/issues/42 | owner/repo#42 |
https://github.com/owner/repo/pull/42 | owner/repo#42 |
https://gitlab.*/group/proj/-/issues/42 | group/proj#42 |
Common Patterns
Deployment notification with Jira link
uv run .../matrix-send-e2ee.py "#ops:matrix.org" \
"**Deployed** to production
https://jira.example.com/browse/PROJ-123
- Version: 1.2.3
- Changes: Auth improvements"Status update with mentions
uv run .../matrix-send-e2ee.py "#dev:matrix.org" \
"**Done**: API refactoring complete
@lead:matrix.org ready for review
See #code-review:matrix.org for PR discussion"Share code snippet
uv run .../matrix-send-e2ee.py "#dev:matrix.org" \
"Fix for the auth bug:
\`\`\`python
def validate_token(token):
return token.startswith('valid_')
\`\`\`"Server maintenance with status updates
# 1. Announce (save event ID from output)
uv run .../matrix-send-e2ee.py "#ops:matrix.org" "Starting server maintenance..."
# Output: Event ID: $abc123
# 2. Update status via reaction
uv run .../matrix-react.py "#ops:matrix.org" '$abc123' "checkmark"
# 3. Or add thread update
uv run .../matrix-send-e2ee.py "#ops:matrix.org" "Maintenance complete" --thread '$abc123'Check room before sending
uv run .../matrix-rooms.py | grep -i ops
uv run .../matrix-send-e2ee.py "#ops-team:matrix.org" "Message here"When to Use Each Feature
Deployment notifications:
- Use bold for status
- Use lists for changes
- Link to Jira issue URL (auto-shortened)
Code sharing:
- Use fenced code blocks for multi-line code
- Use inline code for single commands
Team communication:
- Use
@user:serverto notify specific people - Use
#room:serverto reference other rooms - Use
> quotewhen replying to earlier messages
Sensitive information:
- Use
||spoiler||for credentials or secrets in examples
Reading Reactions
Reactions are m.reaction events that reference a target event via relates_to. When reading room history with --json, reactions appear as separate events.
How Reactions Work
- Each reaction is a standalone
m.reactionevent - The
content.m.relates_tofield links it to the original message rel_typeis alwaysm.annotationkeycontains the emoji or text of the reaction
JSON Output Structure
{
"type": "m.reaction",
"sender": "@user:server",
"content": {
"m.relates_to": {
"rel_type": "m.annotation",
"event_id": "$original_message_id",
"key": "👍"
}
}
}Analyzing Reactions Programmatically
Use --json output and filter for m.reaction events:
# Read room history as JSON
uv run skills/matrix-communication/scripts/matrix-read-e2ee.py room-name --limit 200 --json
# Use jq to extract reactions for a specific event
... | jq '[.[] | select(.type == "m.reaction") | {sender: .sender, emoji: .content."m.relates_to".key, target: .content."m.relates_to".event_id}]'Use Case: Polls and Attendance via Reactions
Reactions can serve as lightweight polls. Post a message with options and ask users to react:
1. Send a message with options (e.g., "React with your lunch preference: 🍕 Pizza, 🍔 Burger, 🥗 Salad") 2. Read reactions with --json and group by emoji key 3. Count unique senders per emoji to tally votes
Matrix Skill Setup Guide
Complete setup walkthrough for the Matrix communication skill.
Prerequisites
Before using E2EE features, check dependencies:
# Run health check (checks all dependencies)
python3 skills/matrix-communication/scripts/matrix-doctor.py
# Auto-install missing dependencies
python3 skills/matrix-communication/scripts/matrix-doctor.py --installRequired for E2EE:
matrix-nio[e2e]- Matrix client library with encryption supportlibolm- Olm encryption library, bundled and compiled bypython-olm(Linux installs a pre-built wheel; macOS 26+ is unsupported, see Troubleshooting)
Package manager priority: The doctor script tries: uvx pip > uv pip > pip > pip3
Setup Steps
Step 1: Check if already configured
cat ~/.config/matrix/config.json 2>/dev/null && echo "Config exists" || echo "Not configured"Step 2: Gather information
Ask user for: 1. User ID - e.g., @username:matrix.org or @username:company.com 2. Matrix password - for E2EE device creation (not stored, used once) 3. Bot prefix (optional) - e.g., bot emoji to mark automated messages
Step 3: Discover homeserver URL
Extract the domain from the user ID and discover the homeserver via .well-known:
# Extract domain from user ID (e.g., @user:example.com -> example.com)
MATRIX_DOMAIN="DOMAIN_FROM_USER_ID"
# Discover homeserver URL
curl -s "https://${MATRIX_DOMAIN}/.well-known/matrix/client" | python3 -c "import sys,json; print(json.load(sys.stdin)['m.homeserver']['base_url'])"Example: For @sebastian.mendel:netresearch.de:
- Domain:
netresearch.de - Discovery URL:
https://netresearch.de/.well-known/matrix/client - Returns homeserver:
https://matrix.netresearch.de
Step 4: Create config file
mkdir -p ~/.config/matrix
cat > ~/.config/matrix/config.json << 'EOF'
{
"homeserver": "DISCOVERED_HOMESERVER_URL",
"user_id": "USER_PROVIDED_USER_ID",
"bot_prefix": "🤖"
}
EOF
chmod 600 ~/.config/matrix/config.jsonStep 5: Set up E2EE device (recommended)
Three ways to provide the password:
Option A: Environment variable (recommended for agents)
MATRIX_PASSWORD="USER_PASSWORD" uv run skills/matrix-communication/scripts/matrix-e2ee-setup.pyOption B: Interactive prompt (recommended for users)
uv run skills/matrix-communication/scripts/matrix-e2ee-setup.py
# Script will securely prompt for passwordOption C: Command line argument (use with caution)
set +H && uv run skills/matrix-communication/scripts/matrix-e2ee-setup.py "USER_PASSWORD"This creates a dedicated "Matrix Skill E2EE" device. The password is used once and not stored.
Why environment variable? Avoids shell escaping issues with special characters (!, $, etc.).
Step 6: Add access token to config
After E2EE setup, copy the access token to enable non-E2EE scripts:
ACCESS_TOKEN=$(python3 -c "import json; print(json.load(open('$HOME/.local/share/matrix-skill/store/credentials.json'))['access_token'])")
python3 -c "
import json
config_path = '$HOME/.config/matrix/config.json'
with open(config_path) as f:
config = json.load(f)
config['access_token'] = '$ACCESS_TOKEN'
with open(config_path, 'w') as f:
json.dump(config, f, indent=2)
print('Access token added to config')
"Step 7: Verify setup
uv run skills/matrix-communication/scripts/matrix-e2ee-setup.py --status
uv run skills/matrix-communication/scripts/matrix-rooms.pyStep 8: Set up key backup recovery (optional)
To decrypt old messages sent before your device was created, restore keys from server-side backup:
# Check if backup exists
uv run skills/matrix-communication/scripts/matrix-key-backup.py --status
# Restore with recovery key (from Element → Settings → Security → "Show Recovery Key")
uv run skills/matrix-communication/scripts/matrix-key-backup.py --recovery-key "EsTj qRGp YB4C ..." --import-keysNote on non-interactive contexts: All scripts use line buffering (sys.stdout.reconfigure(line_buffering=True)) to prevent output from hanging in piped/non-interactive environments like Claude Code.
Troubleshooting
E2EE setup fails with "Invalid username or password":
If your password contains special characters (!, $, \, etc.), bash may mangle them:
# WRONG - bash corrupts passwords with special characters
uv run .../matrix-e2ee-setup.py "MyPass!word"
# CORRECT - use environment variable (recommended)
MATRIX_PASSWORD="MyPass!word" uv run .../matrix-e2ee-setup.py
# CORRECT - use interactive prompt
uv run .../matrix-e2ee-setup.pyE2EE setup fails with libolm error:
# Debian/Ubuntu
sudo apt install libolm-dev
# Fedora
sudo dnf install libolm-develmacOS 26 (Tahoe) / Apple Clang 17 — `brew install libolm` does NOT help.
matrix-nio[e2e] pulls in python-olm, which has no macOS wheel on PyPI and compiles a bundled copy of libolm from source, statically linked — it never uses the Homebrew library. That bundled build fails under Apple Clang 17 / CMake ≥ 3.30 (a C++ const-correctness hard error in list.hh, plus an obsolete cmake_minimum_required(VERSION 3.4)).
Workarounds, easiest first:
- Use the non-E2EE scripts (
matrix-send.py,matrix-rooms.py, …) — they don't needpython-olm. - Run the E2EE scripts from Linux or a Linux container, where the pre-built wheel installs cleanly.
- Build
python-olmon macOS with GCC instead of Clang (community-reported in https://github.com/matrix-nio/matrix-nio/issues/541; not verified by this project):
brew install gcc@12
export CC=/opt/homebrew/bin/gcc-12
export CXX=/opt/homebrew/bin/g++-12
export CMAKE_POLICY_VERSION_MINIMUM=3.5 # clears the CMake < 3.5 error
pip install 'matrix-nio[e2e]' # GCC sidesteps the Clang 17 const errorUpstream status: libolm is archived and deprecated in favor of vodozemac (https://github.com/matrix-nio/matrix-nio/issues/518). The real fix — replacing olm with vodozemac in matrix-nio — is in progress as open PR https://github.com/matrix-nio/matrix-nio/pull/555; until it ships, macOS installs need one of the workarounds above. Related: https://github.com/matrix-nio/matrix-nio/issues/560 (macOS install) and https://github.com/matrix-nio/matrix-nio/issues/541 (CMake error). Tracking here: https://github.com/netresearch/matrix-skill/issues/43
Non-E2EE scripts fail with "Config missing required fields: access_token":
After E2EE setup, the access token is stored separately. Copy it to the main config using Step 6 above.
Bash Quoting Notes
Bash history expansion treats ! specially, which can corrupt messages and passwords.
# MOST RELIABLE - disable history expansion
set +H && uv run .../matrix-send-e2ee.py "#room:server" "Done!"
# Single quotes work for simple messages
uv run .../matrix-send-e2ee.py "#room:server" 'Done!'
# For passwords, use environment variable
MATRIX_PASSWORD="MyP@ss!word" uv run .../matrix-e2ee-setup.py"""Matrix Skill shared library.
This module provides common functionality for all Matrix scripts.
All modules use ONLY stdlib to ensure non-E2EE scripts work without dependencies.
Usage:
# At the top of each script, add:
import sys, os
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
# Then import what you need:
from _lib import load_config, matrix_request, find_room_by_name
"""
# Config
from _lib.config import get_config_path, load_config
# HTTP API
from _lib.http import matrix_request
# Room operations
from _lib.rooms import (
resolve_room_alias,
get_room_info,
list_joined_rooms,
find_room_by_name,
find_room_in_nio_client,
)
# Formatting
from _lib.formatting import (
shorten_service_urls,
markdown_to_html,
add_bot_prefix,
)
# Utilities
from _lib.utils import (
clean_message,
format_timestamp,
prefer_ipv4,
suppress_nio_logging,
)
# Dependency checking
from _lib.deps import check_e2ee_dependencies
# E2EE (only used by E2EE scripts, but still stdlib-only)
from _lib.e2ee import (
get_store_path,
get_credentials_path,
load_credentials,
save_credentials,
delete_credentials,
)
__all__ = [
# Config
"get_config_path",
"load_config",
# HTTP
"matrix_request",
# Rooms
"resolve_room_alias",
"get_room_info",
"list_joined_rooms",
"find_room_by_name",
"find_room_in_nio_client",
# Formatting
"shorten_service_urls",
"markdown_to_html",
"add_bot_prefix",
# Utils
"clean_message",
"format_timestamp",
"prefer_ipv4",
"suppress_nio_logging",
# Deps
"check_e2ee_dependencies",
# E2EE
"get_store_path",
"get_credentials_path",
"load_credentials",
"save_credentials",
"delete_credentials",
]
"""Configuration loading for Matrix scripts.
All functions use ONLY stdlib.
"""
import json
import os
import sys
from pathlib import Path
def get_config_path() -> Path:
"""Get the Matrix configuration file path.
Returns ~/.config/matrix/config.json (respects XDG_CONFIG_HOME if set).
"""
xdg_config = os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config")
return Path(xdg_config) / "matrix" / "config.json"
def load_config(require_user_id: bool = False) -> dict:
"""Load Matrix config from ~/.config/matrix/config.json.
Args:
require_user_id: If True, require user_id field (for E2EE scripts)
Returns:
dict with homeserver, access_token, and optionally user_id, bot_prefix
Exits with error if config not found or missing required fields.
"""
config_path = get_config_path()
if not config_path.exists():
print(f"Error: Config file not found: {config_path}", file=sys.stderr)
print("Create it with:", file=sys.stderr)
example = {
"homeserver": "https://matrix.org",
"access_token": "syt_...",
}
if require_user_id:
example["user_id"] = "@user:matrix.org"
print(json.dumps(example, indent=2), file=sys.stderr)
sys.exit(1)
with open(config_path) as f:
config = json.load(f)
# Validate required fields
required = ["homeserver"]
if require_user_id:
required.append("user_id")
else:
required.append("access_token")
missing = [f for f in required if f not in config]
if missing:
print(
f"Error: Config missing required fields: {', '.join(missing)}",
file=sys.stderr,
)
sys.exit(1)
return config
"""Dependency checking for E2EE scripts.
All functions use ONLY stdlib.
"""
import os
import sys
def check_e2ee_dependencies() -> None:
"""Check that matrix-nio[e2e] dependencies are available.
Prints helpful installation instructions and exits with code 1
if dependencies are missing. Call this before importing nio.
"""
try:
from nio import AsyncClient # noqa: F401
except ImportError as e:
error_msg = str(e).lower()
script_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
if "olm" in error_msg:
print("Error: libolm library not found.", file=sys.stderr)
print("", file=sys.stderr)
print("Install libolm for your platform:", file=sys.stderr)
print(" Debian/Ubuntu: sudo apt install libolm-dev", file=sys.stderr)
print(" Fedora: sudo dnf install libolm-devel", file=sys.stderr)
print(" macOS 26+: brew won't help; python-olm", file=sys.stderr)
print(" build fails (Clang 17), see setup-guide.md.", file=sys.stderr)
elif "nio" in error_msg or "matrix" in error_msg:
print("Error: matrix-nio library not found.", file=sys.stderr)
print("", file=sys.stderr)
print("Install with (try in order):", file=sys.stderr)
print(" uvx pip install 'matrix-nio[e2e]'", file=sys.stderr)
print(" pip install 'matrix-nio[e2e]'", file=sys.stderr)
print(" pip3 install 'matrix-nio[e2e]'", file=sys.stderr)
else:
print(f"Error: Missing dependency: {e}", file=sys.stderr)
print("", file=sys.stderr)
print("Or run the health check to diagnose and fix:", file=sys.stderr)
print(f" python3 {script_dir}/matrix-doctor.py --install", file=sys.stderr)
sys.exit(1)
"""E2EE credential management for Matrix scripts.
All functions use ONLY stdlib - no nio dependencies here.
The actual E2EE functionality (using nio) is in the scripts themselves.
"""
import json
import os
from pathlib import Path
def get_store_path() -> Path:
"""Get or create the E2EE key store directory.
Uses XDG_DATA_HOME or falls back to ~/.local/share/matrix-skill/store
"""
xdg_data = os.environ.get("XDG_DATA_HOME", Path.home() / ".local" / "share")
store_path = Path(xdg_data) / "matrix-skill" / "store"
store_path.mkdir(parents=True, exist_ok=True)
return store_path
def get_credentials_path() -> Path:
"""Get path for stored E2EE device credentials."""
return get_store_path() / "credentials.json"
def load_credentials() -> dict | None:
"""Load stored device credentials if they exist.
Returns:
Dict with user_id, device_id, access_token, or None if not found
"""
creds_path = get_credentials_path()
if creds_path.exists():
with open(creds_path) as f:
return json.load(f)
return None
def save_credentials(user_id: str, device_id: str, access_token: str):
"""Save device credentials for future use.
Credentials file is chmod 600 for security.
"""
creds_path = get_credentials_path()
with open(creds_path, "w") as f:
json.dump(
{
"user_id": user_id,
"device_id": device_id,
"access_token": access_token,
},
f,
indent=2,
)
os.chmod(creds_path, 0o600)
def delete_credentials():
"""Remove stored device credentials and key store files."""
creds_path = get_credentials_path()
if creds_path.exists():
creds_path.unlink()
# Also remove key store databases
store_path = get_store_path()
for db_file in store_path.glob("*.db"):
db_file.unlink()
for key_file in store_path.glob("*_devices"):
key_file.unlink()
"""Message formatting for Matrix scripts.
All functions use ONLY stdlib.
"""
import re
def shorten_service_urls(text: str) -> str:
"""Convert service URLs to shorter linked text.
Supported services:
- Jira: https://jira.example.com/browse/PROJ-123 -> [PROJ-123](url)
- GitHub Issues/PRs: https://github.com/owner/repo/issues/123 -> [owner/repo#123](url)
- GitHub commits: https://github.com/owner/repo/commit/abc123 -> [owner/repo@abc123](url)
- GitLab Issues/MRs: https://gitlab.example.com/group/project/-/issues/123 -> [group/project#123](url)
URLs already inside `[text](url)` markdown links are left untouched so the
caller's chosen link text is preserved (and to avoid producing broken
nested `[text]([short](url))` shapes).
"""
# Protect existing markdown links from being re-wrapped.
protected: list[str] = []
def _protect(match: "re.Match[str]") -> str:
idx = len(protected)
protected.append(match.group(0))
return f"\x00MDLINK{idx}\x00"
text = re.sub(r"\[[^\]]+\]\([^)]+\)", _protect, text)
# Jira URLs: https://jira.*/browse/PROJ-123 or https://*.atlassian.net/browse/PROJ-123
text = re.sub(
r"https?://[^/]+/browse/([A-Z][A-Z0-9]+-\d+)", r"[\1](https://\g<0>)", text
)
# Fix double https
text = re.sub(r"\(https://https?://", r"(https://", text)
# GitHub Issues/PRs: https://github.com/owner/repo/issues/123 or /pull/123
text = re.sub(
r"https?://github\.com/([^/]+)/([^/]+)/(issues|pull)/(\d+)",
r"[\1/\2#\4](\g<0>)",
text,
)
# GitHub commits: https://github.com/owner/repo/commit/abc123...
text = re.sub(
r"https?://github\.com/([^/]+)/([^/]+)/commit/([a-f0-9]{7,40})",
r"[\1/\2@\3](\g<0>)",
text,
)
# GitLab Issues/MRs: https://gitlab.*/group/project/-/issues/123 or /-/merge_requests/123
text = re.sub(
r"https?://[^/]+/([^/]+/[^/]+)/-/(issues|merge_requests)/(\d+)",
r"[\1#\3](\g<0>)",
text,
)
# Restore protected markdown links. Bounds-check the index so a forged
# placeholder in user input can't raise IndexError — unknown placeholders
# are left as-is.
def _restore(match: "re.Match[str]") -> str:
idx = int(match.group(1))
if 0 <= idx < len(protected):
return protected[idx]
return match.group(0)
text = re.sub(r"\x00MDLINK(\d+)\x00", _restore, text)
return text
def markdown_to_html(text: str) -> str:
"""Convert markdown to Matrix HTML with smart features.
Supports:
- ## headings (h1-h6)
- **bold**, *italic*, `code`, ~~strikethrough~~
- [text](url) links
- ||spoiler|| text (Discord-style)
- ```lang code blocks ```
- > blockquotes
- list items: `- item`, `* item`, `+ item`
- | table | rows |
- @user:server mentions (clickable pills)
- #room:server room links (clickable)
- Auto-shortens Jira, GitHub, GitLab URLs
"""
# First, shorten service URLs (before other processing)
html = shorten_service_urls(text)
# Extract and protect code blocks from other processing
code_blocks = []
def save_code_block(match):
lang = match.group(1) or ""
code = match.group(2)
idx = len(code_blocks)
if lang:
code_blocks.append(
f'<pre><code class="language-{lang}">{code}</code></pre>'
)
else:
code_blocks.append(f"<pre><code>{code}</code></pre>")
return f"{{{{CODEBLOCK_{idx}}}}}"
html = re.sub(r"```(\w*)\n(.*?)```", save_code_block, html, flags=re.DOTALL)
# Spoilers: ||text|| -> <span data-mx-spoiler>text</span>
# But not table separators - check for pipe at start/end of line
html = re.sub(
r"(?<!\|)\|\|(.+?)\|\|(?!\|)", r"<span data-mx-spoiler>\1</span>", html
)
# Markdown links: [text](url) -> <a href="url">text</a>
html = re.sub(r"\[([^\]]+)\]\(([^)]+)\)", r'<a href="\2">\1</a>', html)
# Matrix user mentions: @user:server -> clickable pill
# Only match if not already inside a link
html = re.sub(
r'(?<!["\'/])(@[a-zA-Z0-9._=-]+:[a-zA-Z0-9.-]+\.[a-zA-Z]{2,})',
r'<a href="https://matrix.to/#/\1">\1</a>',
html,
)
# Matrix room links: #room:server -> clickable link
# Only match if not already inside a link
html = re.sub(
r'(?<!["\'/])(#[a-zA-Z0-9._=-]+:[a-zA-Z0-9.-]+\.[a-zA-Z]{2,})',
r'<a href="https://matrix.to/#/\1">\1</a>',
html,
)
# Emphasis runs follow CommonMark left/right-flanking: the opening
# delimiter must NOT be followed by whitespace, and the closing one
# must NOT be preceded by whitespace. Without that, `* item` (a bullet)
# would be parsed as an italic opener and chew up the line until the
# next stray `*`.
# Strikethrough: ~~text~~ -> <del>text</del>
html = re.sub(r"~~(?=\S)(.+?)(?<=\S)~~", r"<del>\1</del>", html)
# Bold: **text** -> <strong>text</strong>
html = re.sub(r"\*\*(?=\S)(.+?)(?<=\S)\*\*", r"<strong>\1</strong>", html)
# Italic: *text* -> <em>text</em>
html = re.sub(r"\*(?=\S)([^*\n]+?)(?<=\S)\*", r"<em>\1</em>", html)
# Inline code: `text` -> <code>text</code>
html = re.sub(r"`(.+?)`", r"<code>\1</code>", html)
# Normalize multiple newlines
html = re.sub(r"\n{2,}", "\n", html)
# Process line-based formatting (headings, lists, blockquotes, tables)
lines = html.split("\n")
in_list = False
in_quote = False
in_table = False
result = []
for line in lines:
stripped = line.strip()
# Headings: ## Heading -> <h2>
heading_match = re.match(r"^(#{1,6})\s+(.+)$", stripped)
if heading_match:
if in_quote:
result.append("</blockquote>")
in_quote = False
if in_list:
result.append("</ul>")
in_list = False
if in_table:
result.append("</table>")
in_table = False
level = len(heading_match.group(1))
heading_text = heading_match.group(2)
result.append(f"<h{level}>{heading_text}</h{level}>")
continue
# Tables: | col | col |
if stripped.startswith("|") and stripped.endswith("|"):
# Parse table cells
cells = [c.strip() for c in stripped.split("|")[1:-1]]
# Check if this is a separator line (|---|---|)
if all(re.match(r"^[-:]+$", c) for c in cells if c):
# Skip separator line, it's just formatting
continue
if in_quote:
result.append("</blockquote>")
in_quote = False
if in_list:
result.append("</ul>")
in_list = False
if not in_table:
result.append("<table>")
in_table = True
# First row is header
result.append(
"<tr>" + "".join(f"<th>{c}</th>" for c in cells) + "</tr>"
)
else:
result.append(
"<tr>" + "".join(f"<td>{c}</td>" for c in cells) + "</tr>"
)
continue
# Close table if we're leaving table context
if in_table and not (stripped.startswith("|") and stripped.endswith("|")):
result.append("</table>")
in_table = False
# Blockquotes: > text
if stripped.startswith("> "):
if not in_quote:
if in_list:
result.append("</ul>")
in_list = False
result.append("<blockquote>")
in_quote = True
result.append(stripped[2:])
# Lists: `- item`, `* item`, `+ item` (all valid CommonMark bullets)
elif stripped[:2] in ("- ", "* ", "+ "):
if in_quote:
result.append("</blockquote>")
in_quote = False
if not in_list:
result.append("<ul>")
in_list = True
result.append(f"<li>{stripped[2:]}</li>")
elif stripped == "":
# End blockquote on empty line
if in_quote:
result.append("</blockquote>")
in_quote = False
continue
else:
if in_quote:
result.append("</blockquote>")
in_quote = False
if in_list:
result.append("</ul>")
in_list = False
result.append(line)
# Close any open tags
if in_quote:
result.append("</blockquote>")
if in_list:
result.append("</ul>")
if in_table:
result.append("</table>")
# Join with special marker, then convert to <br> only outside block elements
html = "{{BR}}".join(result)
# Don't add <br> around block elements
html = re.sub(
r"\{\{BR\}\}(?=<ul>|<li>|</ul>|</li>|<blockquote>|</blockquote>|<pre>|<table>|<tr>|</table>|<h[1-6]>)",
"",
html,
)
html = re.sub(
r"(</ul>|</li>|</blockquote>|</pre>|</table>|</tr>|</h[1-6]>)\{\{BR\}\}",
r"\1",
html,
)
html = re.sub(r"(<blockquote>|<table>)\{\{BR\}\}", r"\1", html)
html = html.replace("{{BR}}", "<br>")
# Restore code blocks
for idx, block in enumerate(code_blocks):
html = html.replace(f"{{{{CODEBLOCK_{idx}}}}}", block)
return html
def add_bot_prefix(message: str, prefix: str) -> str:
"""Add bot prefix intelligently.
If message starts with a heading, insert prefix after the heading.
Otherwise, prepend prefix to the message.
"""
lines = message.split("\n")
if not lines:
return f"{prefix} {message}"
first_line = lines[0].strip()
# Check if first line is a heading
if re.match(r"^#{1,6}\s+", first_line):
# Insert prefix after heading on same line or next line
lines[0] = first_line
if len(lines) > 1:
# Insert prefix at start of content after heading
lines.insert(1, f"\n{prefix}")
else:
# Add prefix after heading
lines.append(f"\n{prefix}")
return "\n".join(lines)
else:
# Prepend prefix to message
return f"{prefix} {message}"
"""HTTP-based Matrix API requests.
All functions use ONLY stdlib.
"""
import contextlib
import json
import socket
import urllib.parse
import urllib.request
import urllib.error
_ALLOWED_SCHEMES = frozenset({"http", "https"})
def _require_http_scheme(url: str) -> None:
scheme = urllib.parse.urlparse(url).scheme
if scheme not in _ALLOWED_SCHEMES:
raise ValueError(
f"Refusing to fetch URL with scheme {scheme!r}; only http/https allowed"
)
@contextlib.contextmanager
def _prefer_ipv4():
"""Temporarily prefer IPv4 addresses in DNS resolution.
Workaround for WSL2 environments where IPv6 routes are often
unreachable while IPv4 works fine.
"""
original = socket.getaddrinfo
def patched(*args, **kwargs):
results = original(*args, **kwargs)
return sorted(results, key=lambda r: r[0] != socket.AF_INET)
socket.getaddrinfo = patched
try:
yield
finally:
socket.getaddrinfo = original
def _do_request(req: urllib.request.Request) -> dict:
"""Execute a request and return parsed JSON response.
The caller must have validated the URL scheme via
``_require_http_scheme`` before constructing ``req``.
"""
_require_http_scheme(req.full_url)
# nosemgrep: python.lang.security.audit.dynamic-urllib-use-detected.dynamic-urllib-use-detected
with urllib.request.urlopen(req) as response: # noqa: S310 — scheme validated above
return json.loads(response.read().decode())
def _parse_http_error(e: urllib.error.HTTPError) -> dict:
"""Parse an HTTPError into a result dict."""
error_body = e.read().decode()
try:
error_json = json.loads(error_body)
return {
"error": error_json.get("error", error_body),
"errcode": error_json.get("errcode"),
}
except json.JSONDecodeError:
return {"error": error_body, "errcode": str(e.code)}
def matrix_request(config: dict, method: str, endpoint: str, data: dict = None) -> dict:
"""Make a Matrix API request.
Args:
config: Dict with homeserver and access_token
method: HTTP method (GET, POST, PUT, DELETE)
endpoint: API endpoint (e.g., /joined_rooms)
data: Optional dict to send as JSON body
Returns:
Response dict, or dict with 'error' key on failure
"""
url = f"{config['homeserver']}/_matrix/client/v3{endpoint}"
_require_http_scheme(url)
headers = {
"Authorization": f"Bearer {config['access_token']}",
"Content-Type": "application/json",
}
body = json.dumps(data).encode() if data is not None else None
req = urllib.request.Request(url, data=body, headers=headers, method=method)
try:
return _do_request(req)
except urllib.error.HTTPError as e:
return _parse_http_error(e)
except OSError as e:
if e.errno not in (101, 113): # ENETUNREACH, EHOSTUNREACH
return {"error": str(e)}
# IPv6 likely unreachable — retry with IPv4 preference
try:
with _prefer_ipv4():
req2 = urllib.request.Request(
url, data=body, headers=headers, method=method
)
return _do_request(req2)
except urllib.error.HTTPError as e2:
return _parse_http_error(e2)
except OSError as e2:
return {"error": str(e2)}
"""Room operations for Matrix scripts.
All functions use ONLY stdlib.
"""
import urllib.parse
from _lib.http import matrix_request
def resolve_room_alias(config: dict, alias: str) -> str:
"""Resolve a room alias to room ID.
Args:
config: Matrix config with homeserver and access_token
alias: Room alias (e.g., #room:server)
Returns:
Room ID (e.g., !abc123:server)
Raises:
ValueError if alias cannot be resolved
"""
encoded_alias = urllib.parse.quote(alias, safe="")
result = matrix_request(config, "GET", f"/directory/room/{encoded_alias}")
if "room_id" in result:
return result["room_id"]
raise ValueError(
f"Could not resolve room alias: {result.get('error', 'Unknown error')}"
)
def get_room_info(config: dict, room_id: str) -> dict:
"""Get the display name and canonical alias of a room.
Args:
config: Matrix config with homeserver and access_token
room_id: Room ID to query
Returns:
Dict with 'name' and 'alias' keys (values may be None)
"""
info = {"name": None, "alias": None}
result = matrix_request(config, "GET", f"/rooms/{room_id}/state/m.room.name")
if "name" in result:
info["name"] = result["name"]
result = matrix_request(
config, "GET", f"/rooms/{room_id}/state/m.room.canonical_alias"
)
if "alias" in result:
info["alias"] = result["alias"]
return info
def list_joined_rooms(config: dict) -> list:
"""List all joined rooms with names and aliases.
Args:
config: Matrix config with homeserver and access_token
Returns:
List of dicts with room_id, name, and alias keys
"""
result = matrix_request(config, "GET", "/joined_rooms")
if "error" in result:
return []
rooms = []
for room_id in result.get("joined_rooms", []):
info = get_room_info(config, room_id)
display_name = info["name"] or info["alias"] or room_id
rooms.append({"room_id": room_id, "name": display_name, "alias": info["alias"]})
return rooms
def find_room_by_name(config: dict, search_term: str) -> tuple[str | None, list]:
"""Find a room by name or alias (case-insensitive).
Match priority:
1. Exact alias match (#room:server)
2. Exact alias name match (without server part, e.g. "agent-work")
3. Exact room name match (rooms with aliases preferred)
4. Single partial match on name or alias
When an exact name match has no alias but other rooms also have names
containing the search term, all candidates are returned for disambiguation.
Args:
config: Matrix config with homeserver and access_token
search_term: Search term to match against room names/aliases
Returns:
(room_id, matches) where:
- room_id is the matched room ID (or None if no/ambiguous match)
- matches is list of matching rooms (for error reporting)
"""
rooms = list_joined_rooms(config)
search_lower = search_term.lower()
# Try exact alias match (most specific)
for room in rooms:
if room.get("alias") and room["alias"].lower() == search_lower:
return room["room_id"], [room]
# Try exact alias name match (without server part)
for room in rooms:
if room.get("alias"):
alias_name = room["alias"].split(":")[0].lstrip("#")
if alias_name.lower() == search_lower:
return room["room_id"], [room]
# Try exact name match
name_matches = [r for r in rooms if r["name"].lower() == search_lower]
if len(name_matches) == 1:
room = name_matches[0]
if room.get("alias"):
# Room has an alias — well-identified, return directly
return room["room_id"], name_matches
# Room has no alias — check if other rooms have names containing
# the search term, which suggests the user may want a different room
alternatives = [
r
for r in rooms
if r not in name_matches and search_lower in r["name"].lower()
]
if alternatives:
return None, name_matches + alternatives
return room["room_id"], name_matches
if len(name_matches) > 1:
return None, name_matches
# Try partial match
matches = []
for room in rooms:
if search_lower in room["name"].lower():
matches.append(room)
elif room.get("alias") and search_lower in room["alias"].lower():
if room not in matches:
matches.append(room)
if len(matches) == 1:
return matches[0]["room_id"], matches
return None, matches
def find_room_in_nio_client(client_rooms: dict, search_term: str) -> str | None:
"""Find a room by name in a matrix-nio client.rooms dict (post-sync).
This avoids the N+1 HTTP calls of find_room_by_name() by using
room data already loaded by client.sync().
Args:
client_rooms: dict from AsyncClient.rooms (room_id -> MatrixRoom)
search_term: Room name, alias, or ID to match
Returns:
room_id if found, None otherwise
"""
search_lower = search_term.lower()
# Exact alias match
for room_id, room in client_rooms.items():
if room.canonical_alias and room.canonical_alias.lower() == search_lower:
return room_id
# Alias name match (without server part)
for room_id, room in client_rooms.items():
if room.canonical_alias:
alias_name = room.canonical_alias.split(":")[0].lstrip("#")
if alias_name.lower() == search_lower:
return room_id
# Exact display name match
exact_matches = [
room_id
for room_id, room in client_rooms.items()
if room.display_name and room.display_name.lower() == search_lower
]
if len(exact_matches) == 1:
return exact_matches[0]
if len(exact_matches) > 1:
return None # Ambiguous exact match
# Partial name match
partial_matches = [
room_id
for room_id, room in client_rooms.items()
if search_lower in (room.display_name or "").lower()
or search_lower in (room.canonical_alias or "").lower()
]
if len(partial_matches) == 1:
return partial_matches[0]
return None
"""Tests for `_lib.formatting` markdown→HTML conversion.
The skill directory contains a hyphen (`matrix-communication`) so it is
not importable as a Python package; run the file directly or use unittest
discovery:
python3 skills/matrix-communication/scripts/_lib/test_formatting.py
python3 -m unittest discover \\
-s skills/matrix-communication/scripts/_lib -p 'test_formatting.py'
Stdlib only.
"""
import os
import sys
import unittest
sys.path.insert(0, os.path.dirname(__file__))
from formatting import markdown_to_html, shorten_service_urls # noqa: E402
class ShortenServiceUrlsTests(unittest.TestCase):
def test_jira_bare_url_is_shortened(self):
out = shorten_service_urls("see https://jira.example.com/browse/PROJ-42")
self.assertIn("[PROJ-42](https://jira.example.com/browse/PROJ-42)", out)
def test_gitlab_bare_url_is_shortened(self):
out = shorten_service_urls(
"see https://gitlab.example.com/grp/proj/-/merge_requests/7"
)
self.assertIn(
"[grp/proj#7](https://gitlab.example.com/grp/proj/-/merge_requests/7)",
out,
)
def test_existing_gitlab_markdown_link_is_preserved(self):
"""Regression: pre-wrapped markdown links must NOT be re-wrapped.
Before the fix this produced `[mytext]([grp/proj#7](url))` which
confused the link parser and leaked literal `)` into the rendered
message.
"""
src = "[mytext](https://gitlab.example.com/grp/proj/-/merge_requests/7) — note"
out = shorten_service_urls(src)
self.assertEqual(out, src)
def test_existing_jira_markdown_link_is_preserved(self):
src = "[NRS-1](https://jira.example.com/browse/NRS-1) — note"
out = shorten_service_urls(src)
self.assertEqual(out, src)
def test_existing_github_markdown_link_is_preserved(self):
src = "[my pr](https://github.com/owner/repo/pull/9) — note"
out = shorten_service_urls(src)
self.assertEqual(out, src)
def test_mixed_protected_and_bare_urls(self):
src = (
"see [my mr](https://gitlab.example.com/grp/proj/-/merge_requests/7) "
"and https://gitlab.example.com/grp/proj/-/issues/12"
)
out = shorten_service_urls(src)
# First link untouched
self.assertIn(
"[my mr](https://gitlab.example.com/grp/proj/-/merge_requests/7)", out
)
# Second link auto-shortened
self.assertIn(
"[grp/proj#12](https://gitlab.example.com/grp/proj/-/issues/12)", out
)
def test_forged_placeholder_does_not_raise(self):
"""A user-supplied `\\x00MDLINK<n>\\x00` sequence must not crash the
restore step with IndexError. Unknown placeholders are left as-is."""
# No real markdown link in the input, but a forged placeholder is.
src = "literal placeholder \x00MDLINK99\x00 should pass through"
out = shorten_service_urls(src)
self.assertIn("\x00MDLINK99\x00", out)
class MarkdownToHtmlListTests(unittest.TestCase):
def test_dash_bullets_render_as_ul(self):
html = markdown_to_html("- one\n- two")
self.assertIn("<ul>", html)
self.assertIn("<li>one</li>", html)
self.assertIn("<li>two</li>", html)
def test_asterisk_bullets_render_as_ul(self):
"""Regression: `* ` bullets must render as a list, not as italic."""
html = markdown_to_html("* one\n* two")
self.assertIn("<ul>", html)
self.assertIn("<li>one</li>", html)
self.assertIn("<li>two</li>", html)
self.assertNotIn("<em>", html)
def test_plus_bullets_render_as_ul(self):
html = markdown_to_html("+ one\n+ two")
self.assertIn("<ul>", html)
self.assertIn("<li>one</li>", html)
self.assertIn("<li>two</li>", html)
def test_asterisk_bullet_with_inline_italic(self):
"""A bullet line may still contain an italic span elsewhere."""
html = markdown_to_html("* item *emph* tail")
self.assertIn("<li>item <em>emph</em> tail</li>", html)
def test_asterisk_inside_word_still_italicises(self):
"""Mid-line `*pairs*` still emit <em>."""
html = markdown_to_html("hello *world*")
self.assertIn("<em>world</em>", html)
class EmphasisFlankingTests(unittest.TestCase):
"""CommonMark left/right-flanking rules for `*…*`, `**…**`, `~~…~~`.
Opening delimiter must NOT be followed by whitespace; closing delimiter
must NOT be preceded by whitespace. Without these guards the italic
regex used to swallow bullet `*` markers and produce broken output.
"""
def test_italic_open_followed_by_space_does_not_match(self):
# `*` immediately followed by space is not a valid italic opener.
html = markdown_to_html("* one and *two*")
self.assertNotIn("<em> one and </em>", html)
self.assertIn("<em>two</em>", html)
def test_italic_close_preceded_by_space_does_not_match(self):
# `*` immediately preceded by space is not a valid italic closer.
html = markdown_to_html("a *foo *bar")
self.assertNotIn("<em>foo </em>", html)
def test_bold_open_followed_by_space_does_not_match(self):
html = markdown_to_html("** not bold ** but **bold**")
self.assertNotIn("<strong> not bold </strong>", html)
self.assertIn("<strong>bold</strong>", html)
def test_strikethrough_open_followed_by_space_does_not_match(self):
html = markdown_to_html("~~ not strike ~~ but ~~strike~~")
self.assertNotIn("<del> not strike </del>", html)
self.assertIn("<del>strike</del>", html)
def test_italic_single_char(self):
# `*x*` is a valid italic: opener followed by non-ws, closer
# preceded by non-ws.
html = markdown_to_html("*x*")
self.assertIn("<em>x</em>", html)
class MarkdownToHtmlLinkTests(unittest.TestCase):
def test_pre_wrapped_gitlab_link_renders_clean_anchor(self):
"""Regression: no stray `)` after the anchor."""
src = "[my mr](https://gitlab.example.com/grp/proj/-/merge_requests/7)"
html = markdown_to_html(src)
self.assertIn(
'<a href="https://gitlab.example.com/grp/proj/-/merge_requests/7">'
"my mr</a>",
html,
)
# Critical: no leaked closing paren after the anchor
self.assertNotIn("</a>)", html)
def test_pre_wrapped_jira_link_renders_clean_anchor(self):
src = "[NRS-1](https://jira.example.com/browse/NRS-1)"
html = markdown_to_html(src)
self.assertIn('<a href="https://jira.example.com/browse/NRS-1">NRS-1</a>', html)
self.assertNotIn("</a>)", html)
def test_bullet_list_with_pre_wrapped_links(self):
"""End-to-end regression: a bullet list of pre-wrapped GitLab + Jira
links must produce a clean `<ul><li><a>…</a></li>…</ul>` tree, with
the pre-wrapped link text preserved (not replaced by the auto-shortener).
"""
src = (
"- [proj#7](https://gitlab.example.com/grp/proj/-/merge_requests/7) "
"— note ([NRS-1](https://jira.example.com/browse/NRS-1))\n"
"- [proj#8](https://gitlab.example.com/grp/proj/-/merge_requests/8)"
)
html = markdown_to_html(src)
self.assertIn("<ul>", html)
# Caller-provided link text preserved (would have been clobbered to
# `grp/proj#7` by the broken auto-shortener)
self.assertIn(
'<a href="https://gitlab.example.com/grp/proj/-/merge_requests/7">'
"proj#7</a>",
html,
)
self.assertIn('<a href="https://jira.example.com/browse/NRS-1">NRS-1</a>', html)
# No nested `[…](…)` artefacts (the symptom of broken re-wrapping)
self.assertNotIn("[", html)
self.assertNotIn("](", html)
# Sanity: no leftover bullet asterisks (would mean inline ate the bullet)
self.assertNotIn("<li>*", html)
if __name__ == "__main__":
unittest.main()
"""Utility functions for Matrix scripts.
All functions use ONLY stdlib.
"""
import logging
import socket
from datetime import datetime
def clean_message(message: str) -> str:
"""Clean message from bash escaping artifacts.
Bash history expansion in interactive shells can escape ! to \\!
when using double quotes. This removes those artifacts.
"""
# Remove backslash before ! (bash history expansion artifact)
return message.replace("\\!", "!")
def prefer_ipv4():
"""Monkey-patch socket.getaddrinfo to prefer IPv4 results.
Workaround for WSL2 environments where IPv6 routes are often
unreachable while IPv4 works fine. Call once at script startup.
"""
_orig = socket.getaddrinfo
socket.getaddrinfo = lambda *a, **kw: sorted(
_orig(*a, **kw), key=lambda r: r[0] != socket.AF_INET
)
def suppress_nio_logging():
"""Suppress noisy matrix-nio crypto/sync warnings.
Sets nio and peewee loggers to ERROR level to hide megolm session
warnings that clutter output during normal operation.
"""
for name in ("nio", "nio.crypto", "nio.responses", "peewee"):
logging.getLogger(name).setLevel(logging.ERROR)
def format_timestamp(ts: int) -> str:
"""Format Matrix timestamp to readable string.
Args:
ts: Unix timestamp in milliseconds
Returns:
Formatted string like "2024-01-15 14:30"
"""
if ts == 0:
return "unknown"
dt = datetime.fromtimestamp(ts / 1000)
return dt.strftime("%Y-%m-%d %H:%M")
#!/usr/bin/env python3
# /// script
# requires-python = ">=3.10"
# dependencies = []
# ///
"""Matrix Skill health check and dependency installer.
Checks all dependencies and configuration, installs missing packages,
and reports on E2EE setup status.
Usage:
matrix-doctor.py [--install] [--json] [--quiet]
matrix-doctor.py --help
Options:
--install Automatically install missing dependencies
--json Output as JSON
--quiet Only show errors
--help Show this help
"""
import json
import os
import shutil
import subprocess
import sys
# Add script directory to path for _lib imports
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from _lib.config import get_config_path
from _lib.e2ee import get_store_path
sys.stdout.reconfigure(line_buffering=True)
sys.stderr.reconfigure(line_buffering=True)
def check_command_exists(cmd: str) -> bool:
"""Check if a command exists in PATH."""
return shutil.which(cmd) is not None
def get_pip_command() -> str | None:
"""Get the best available pip command. Priority: uvx, pip, pip3."""
# Check uvx first (preferred)
if check_command_exists("uvx"):
return "uvx pip"
if check_command_exists("uv"):
return "uv pip"
if check_command_exists("pip"):
return "pip"
if check_command_exists("pip3"):
return "pip3"
return None
def run_pip_command(pip_cmd: str, args: list[str]) -> tuple[bool, str]:
"""Run a pip command and return success status and output."""
if pip_cmd.startswith("uvx"):
full_cmd = ["uvx", "pip"] + args
elif pip_cmd.startswith("uv"):
full_cmd = ["uv", "pip"] + args
else:
full_cmd = [pip_cmd] + args
try:
result = subprocess.run(full_cmd, capture_output=True, text=True, timeout=120)
return result.returncode == 0, result.stdout + result.stderr
except subprocess.TimeoutExpired:
return False, "Command timed out"
except Exception as e:
return False, str(e)
def check_matrix_nio_e2ee() -> tuple[bool, str]:
"""Check if matrix-nio with E2EE support is installed."""
try:
import nio # noqa: F401
# Try to get version
try:
from importlib.metadata import version
nio_version = version("matrix-nio")
except Exception:
nio_version = "unknown"
# Check for E2EE support by trying to import olm
try:
from nio.crypto import Olm # noqa: F401
return True, f"matrix-nio {nio_version} with E2EE support"
except ImportError:
return False, f"matrix-nio {nio_version} installed but E2EE deps missing"
except ImportError:
return False, "matrix-nio not installed"
def check_libolm() -> tuple[bool, str]:
"""Check if libolm system library is installed."""
try:
import _libolm # noqa: F401
return True, "libolm available"
except ImportError:
pass
# Try loading the shared library
import ctypes.util
lib = ctypes.util.find_library("olm")
if lib:
return True, f"libolm found: {lib}"
return False, "libolm not found (required for E2EE)"
def check_config() -> tuple[bool, str, dict]:
"""Check Matrix configuration file."""
config_path = get_config_path()
if not config_path.exists():
return False, f"Config not found at {config_path}", {}
try:
with open(config_path) as f:
config = json.load(f)
required = ["homeserver", "user_id"]
missing = [k for k in required if k not in config]
if missing:
return (
False,
f"Config missing required fields: {', '.join(missing)}",
config,
)
return True, f"Config OK: {config.get('user_id')}", config
except json.JSONDecodeError as e:
return False, f"Invalid JSON in config: {e}", {}
except Exception as e:
return False, f"Error reading config: {e}", {}
def check_e2ee_setup() -> tuple[bool, str]:
"""Check E2EE device setup status."""
store_dir = get_store_path()
creds_file = store_dir / "credentials.json"
if not store_dir.exists():
return False, "E2EE not set up (no store directory)"
if not creds_file.exists():
return False, "E2EE not set up (no credentials)"
try:
with open(creds_file) as f:
creds = json.load(f)
device_id = creds.get("device_id", "unknown")
return True, f"E2EE device configured: {device_id}"
except Exception as e:
return False, f"Error reading E2EE credentials: {e}"
def install_dependencies(pip_cmd: str, quiet: bool = False) -> tuple[bool, list[str]]:
"""Install missing dependencies."""
messages = []
# Install matrix-nio with E2EE support
if not quiet:
messages.append("Installing matrix-nio[e2e]...")
success, output = run_pip_command(pip_cmd, ["install", "matrix-nio[e2e]"])
if success:
messages.append("matrix-nio[e2e] installed successfully")
else:
messages.append(f"Failed to install matrix-nio[e2e]: {output}")
return False, messages
return True, messages
def main():
import argparse
parser = argparse.ArgumentParser(description="Matrix Skill health check and setup")
parser.add_argument(
"--install",
action="store_true",
help="Automatically install missing dependencies",
)
parser.add_argument("--json", action="store_true", help="Output as JSON")
parser.add_argument("--quiet", "-q", action="store_true", help="Only show errors")
args = parser.parse_args()
checks = {
"pip_available": {"ok": False, "message": "", "critical": True},
"matrix_nio": {"ok": False, "message": "", "critical": True},
"libolm": {"ok": False, "message": "", "critical": False},
"config": {"ok": False, "message": "", "critical": True},
"e2ee_setup": {"ok": False, "message": "", "critical": False},
}
# Check pip availability
pip_cmd = get_pip_command()
if pip_cmd:
checks["pip_available"]["ok"] = True
checks["pip_available"]["message"] = f"Using: {pip_cmd}"
else:
checks["pip_available"]["message"] = (
"No pip command found (tried: uvx, uv pip, pip, pip3)"
)
# Check matrix-nio
nio_ok, nio_msg = check_matrix_nio_e2ee()
checks["matrix_nio"]["ok"] = nio_ok
checks["matrix_nio"]["message"] = nio_msg
# Check libolm
olm_ok, olm_msg = check_libolm()
checks["libolm"]["ok"] = olm_ok
checks["libolm"]["message"] = olm_msg
# Check config
config_ok, config_msg, config_data = check_config()
checks["config"]["ok"] = config_ok
checks["config"]["message"] = config_msg
# Check E2EE setup
e2ee_ok, e2ee_msg = check_e2ee_setup()
checks["e2ee_setup"]["ok"] = e2ee_ok
checks["e2ee_setup"]["message"] = e2ee_msg
# Auto-install if requested
if args.install and pip_cmd:
if not checks["matrix_nio"]["ok"]:
success, messages = install_dependencies(pip_cmd, args.quiet)
if success:
# Re-check after install
nio_ok, nio_msg = check_matrix_nio_e2ee()
checks["matrix_nio"]["ok"] = nio_ok
checks["matrix_nio"]["message"] = nio_msg
checks["install_messages"] = messages
# Output
if args.json:
print(json.dumps(checks, indent=2))
sys.exit(0 if all(c["ok"] for c in checks.values() if c.get("critical")) else 1)
# Pretty output
all_ok = True
critical_ok = True
if not args.quiet:
print("=" * 60)
print("Matrix Skill Health Check")
print("=" * 60)
print()
for name, check in checks.items():
if name == "install_messages":
continue
icon = "OK" if check["ok"] else "FAIL"
critical = " (required)" if check.get("critical") else ""
if not check["ok"]:
all_ok = False
if check.get("critical"):
critical_ok = False
if not args.quiet or not check["ok"]:
print(f"[{icon}] {name}{critical}")
print(f" {check['message']}")
print()
# Summary
if not args.quiet:
print("=" * 60)
if all_ok:
print("All checks passed! Matrix Skill is ready to use.")
elif critical_ok:
print("Core functionality OK. Some optional features may be limited.")
else:
print("Some required checks failed. See above for details.")
print()
print("Quick fix:")
if not checks["pip_available"]["ok"]:
print(" - Install uv: pip install uv")
if not checks["matrix_nio"]["ok"]:
print(" - Run: matrix-doctor.py --install")
if not checks["config"]["ok"]:
print(" - Set up Matrix: see SKILL.md Setup Guide")
if not checks["e2ee_setup"]["ok"] and checks["config"]["ok"]:
print(" - Run: matrix-e2ee-setup.py")
sys.exit(0 if critical_ok else 1)
if __name__ == "__main__":
main()
#!/usr/bin/env python3
# /// script
# requires-python = ">=3.10"
# dependencies = ["matrix-nio[e2e]"]
# ///
"""Download media from a Matrix room message.
Usage:
matrix-download-e2ee.py ROOM EVENT_ID [--output DIR] [--filename NAME]
matrix-download-e2ee.py --help
Arguments:
ROOM Room alias (#room:server), room ID (!id:server), or room name
EVENT_ID Event ID of the media message ($xxx:server)
Options:
--output DIR Output directory [default: .]
--filename NAME Override filename (default: from message body)
--debug Show debug information
--help Show this help
"""
import argparse
import asyncio
import sys
import os
from pathlib import Path
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from _lib import (
check_e2ee_dependencies,
load_config,
get_store_path,
load_credentials,
find_room_in_nio_client,
prefer_ipv4,
suppress_nio_logging,
)
check_e2ee_dependencies()
from nio import (
AsyncClient,
AsyncClientConfig,
RoomResolveAliasResponse,
RoomGetEventError,
DownloadError,
MemoryDownloadResponse,
)
async def download_media(
config: dict,
room: str,
event_id: str,
output_dir: str = ".",
filename: str | None = None,
debug: bool = False,
) -> str:
"""Download media from a Matrix message event."""
store_path = get_store_path()
stored_creds = load_credentials()
if stored_creds and stored_creds.get("user_id") == config["user_id"]:
device_id = stored_creds["device_id"]
access_token = stored_creds["access_token"]
elif "access_token" in config:
access_token = config["access_token"]
from nio import WhoamiResponse
temp_client = AsyncClient(config["homeserver"], config["user_id"])
temp_client.access_token = access_token
whoami = await temp_client.whoami()
await temp_client.close()
if isinstance(whoami, WhoamiResponse):
device_id = whoami.device_id
else:
raise RuntimeError(f"Failed to get device info: {whoami}")
else:
raise RuntimeError("No credentials found. Run matrix-e2ee-setup.py first.")
client_config = AsyncClientConfig(store_sync_tokens=True, encryption_enabled=True)
client = AsyncClient(
homeserver=config["homeserver"],
user=config["user_id"],
device_id=device_id,
store_path=str(store_path),
config=client_config,
)
try:
client.restore_login(
user_id=config["user_id"], device_id=device_id, access_token=access_token
)
if client.store:
client.load_store()
if client.should_upload_keys:
await client.keys_upload()
# Sync to get room keys
if debug:
print("Syncing...", file=sys.stderr)
await client.sync(timeout=0, full_state=True)
# Resolve room
room_id = room
if room.startswith("#"):
response = await client.room_resolve_alias(room)
if isinstance(response, RoomResolveAliasResponse):
room_id = response.room_id
else:
raise RuntimeError(f"Could not resolve room alias: {response}")
elif not room.startswith("!"):
found = find_room_in_nio_client(client.rooms, room)
if found:
room_id = found
else:
raise RuntimeError(
f"Could not find room '{room}'. Use 'matrix-rooms.py' to list rooms."
)
# Fetch the event
if debug:
print(f"Fetching event {event_id}...", file=sys.stderr)
resp = await client.room_get_event(room_id, event_id)
if isinstance(resp, RoomGetEventError):
raise RuntimeError(f"Failed to get event: {resp.message}")
event = resp.event
source = event.source if hasattr(event, "source") else {}
content = source.get("content", {})
msgtype = content.get("msgtype", "")
if msgtype not in ("m.image", "m.file", "m.video", "m.audio"):
raise RuntimeError(f"Event is not a media message (msgtype: {msgtype})")
# Get mxc URL
if "file" in content:
mxc_url = content["file"]["url"]
elif "url" in content:
mxc_url = content["url"]
else:
raise RuntimeError("No media URL found in event")
if debug:
print(f"Downloading {mxc_url}...", file=sys.stderr)
# Determine filename — sanitize to prevent path traversal
if not filename:
raw_name = content.get("body", "media_download")
filename = Path(raw_name).name # Strip directory components
else:
filename = Path(filename).name
# Download media into memory (don't pass filename to avoid unnecessary disk write)
resp = await client.download(mxc=mxc_url)
if isinstance(resp, DownloadError):
raise RuntimeError(f"Download failed: {resp.message}")
# Get raw bytes
if isinstance(resp, MemoryDownloadResponse):
data = resp.body
else:
data = Path(resp.filename).read_bytes()
# Decrypt E2EE media if encrypted
if "file" in content:
from nio.crypto import decrypt_attachment
file_info = content["file"]
data = decrypt_attachment(
ciphertext=data,
key=file_info["key"]["k"],
hash=file_info["hashes"]["sha256"],
iv=file_info["iv"],
)
# Save to file
out_path = Path(output_dir) / filename
out_path.parent.mkdir(parents=True, exist_ok=True)
out_path.write_bytes(data)
if debug:
print(
f"Saved to {out_path} ({out_path.stat().st_size:,} bytes)",
file=sys.stderr,
)
return str(out_path)
finally:
await client.close()
def main():
parser = argparse.ArgumentParser(description="Download media from Matrix room")
parser.add_argument("room", help="Room alias, ID, or name")
parser.add_argument("event_id", help="Event ID of the media message")
parser.add_argument("--output", default=".", help="Output directory [default: .]")
parser.add_argument("--filename", help="Override filename")
parser.add_argument("--debug", action="store_true", help="Debug output")
args = parser.parse_args()
suppress_nio_logging()
prefer_ipv4()
config = load_config()
try:
path = asyncio.run(
download_media(
config,
args.room,
args.event_id,
output_dir=args.output,
filename=args.filename,
debug=args.debug,
)
)
print(path)
except Exception as e:
if args.debug:
raise
print(f"Error: {e}", file=sys.stderr)
sys.exit(1)
if __name__ == "__main__":
main()
#!/usr/bin/env python3
# /// script
# requires-python = ">=3.10"
# dependencies = ["matrix-nio[e2e]"]
# ///
"""Set up E2EE device for Matrix Skill.
This creates a dedicated Matrix device with encryption keys.
Run once to set up E2EE, then matrix-send-e2ee.py works without password.
Requires libolm system library:
Debian/Ubuntu: sudo apt install libolm-dev
Fedora: sudo dnf install libolm-devel
macOS 26+: unsupported, python-olm build fails (Clang 17); see setup-guide.md
Usage:
matrix-e2ee-setup.py # Interactive password prompt
matrix-e2ee-setup.py PASSWORD # Password as argument
matrix-e2ee-setup.py --status
matrix-e2ee-setup.py --logout
matrix-e2ee-setup.py --help
Options:
--status Check if E2EE device is set up
--logout Remove stored device credentials
--help Show this help
"""
import asyncio
import getpass
import json
import sys
import os
# Add script directory to path for _lib imports
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from _lib import (
check_e2ee_dependencies,
load_config,
get_store_path,
load_credentials,
save_credentials,
delete_credentials,
prefer_ipv4,
suppress_nio_logging,
)
# Check dependencies before importing nio
check_e2ee_dependencies()
from nio import (
AsyncClient,
AsyncClientConfig,
LoginResponse,
)
sys.stdout.reconfigure(line_buffering=True)
sys.stderr.reconfigure(line_buffering=True)
async def setup_device(config: dict, password: str) -> dict:
"""Create a new E2EE device using password login."""
store_path = get_store_path()
client_config = AsyncClientConfig(
store_sync_tokens=True,
encryption_enabled=True,
)
# Extract localpart from user_id for login (some servers require this)
# @user:server -> user
user_id = config["user_id"]
login_user = (
user_id.split(":")[0].lstrip("@") if user_id.startswith("@") else user_id
)
client = AsyncClient(
homeserver=config["homeserver"],
user=login_user,
store_path=str(store_path),
config=client_config,
)
try:
# Login to create new device with hostname suffix
import socket
hostname = socket.gethostname()
device_name = f"Matrix Skill E2EE @ {hostname}"
login_response = await client.login(
password=password,
device_name=device_name,
)
if isinstance(login_response, LoginResponse):
# Save credentials (password NOT saved)
save_credentials(
user_id=login_response.user_id,
device_id=login_response.device_id,
access_token=login_response.access_token,
)
return {
"success": True,
"device_id": login_response.device_id,
"user_id": login_response.user_id,
}
else:
return {"error": str(login_response)}
finally:
await client.close()
def show_status(config: dict):
"""Show current E2EE setup status."""
creds = load_credentials()
if creds and creds.get("user_id") == config["user_id"]:
print("E2EE Status: SET UP")
print(f" User: {creds['user_id']}")
print(f" Device: {creds['device_id']}")
print(f" Store: {get_store_path()}")
else:
print("E2EE Status: NOT SET UP")
print("")
print("Run setup with your Matrix password:")
print(" matrix-e2ee-setup.py YOUR_PASSWORD")
def main():
import argparse
parser = argparse.ArgumentParser(description="Set up E2EE device for Matrix Skill")
parser.add_argument(
"password", nargs="?", help="Matrix account password (used once, not stored)"
)
parser.add_argument("--status", action="store_true", help="Check E2EE setup status")
parser.add_argument(
"--logout", action="store_true", help="Remove stored device credentials"
)
parser.add_argument("--json", action="store_true", help="Output as JSON")
parser.add_argument("--debug", action="store_true", help="Show debug info")
args = parser.parse_args()
prefer_ipv4()
if not args.debug:
suppress_nio_logging()
config = load_config(require_user_id=True)
if args.status:
if args.json:
creds = load_credentials()
if creds and creds.get("user_id") == config["user_id"]:
print(
json.dumps(
{"status": "configured", "device_id": creds["device_id"]}
)
)
else:
print(json.dumps({"status": "not_configured"}))
else:
show_status(config)
return
if args.logout:
creds = load_credentials()
if creds:
delete_credentials()
if args.json:
print(json.dumps({"success": True, "message": "Credentials removed"}))
else:
print("E2EE device credentials removed.")
print("Note: The device still exists on the server.")
print("To fully remove it, go to Element > Settings > Sessions")
else:
if args.json:
print(json.dumps({"success": False, "message": "No credentials found"}))
else:
print("No E2EE credentials found.")
return
# Check if already set up
creds = load_credentials()
if creds and creds.get("user_id") == config["user_id"]:
if args.json:
print(
json.dumps(
{"status": "already_configured", "device_id": creds["device_id"]}
)
)
else:
print("E2EE already set up!")
print(f"Device: {creds['device_id']}")
print("")
print("To reconfigure, first run: matrix-e2ee-setup.py --logout")
return
# Get password - from argument, environment variable, or interactive prompt
password = args.password or os.environ.get("MATRIX_PASSWORD")
if not password:
print(f"Setting up E2EE device for {config['user_id']}")
print("Password is used once to create device, then not stored.")
print("")
try:
password = getpass.getpass(f"Matrix password for {config['user_id']}: ")
except (KeyboardInterrupt, EOFError):
print("\nAborted.")
sys.exit(1)
if not password:
print("Error: Password cannot be empty.", file=sys.stderr)
sys.exit(1)
# Run setup
result = asyncio.run(setup_device(config, password))
if "error" in result:
if args.json:
print(json.dumps(result))
else:
print(f"Error: {result['error']}", file=sys.stderr)
sys.exit(1)
if args.json:
print(json.dumps(result))
else:
print("E2EE device created successfully!")
print(f" Device ID: {result['device_id']}")
print(f" User: {result['user_id']}")
print("")
print("You can now use matrix-send-e2ee.py without password.")
print("The device appears as 'Matrix Skill E2EE' in your sessions.")
if __name__ == "__main__":
main()
#!/usr/bin/env python3
# /// script
# requires-python = ">=3.10"
# dependencies = ["matrix-nio[e2e]"]
# ///
"""Interactive device verification for Matrix E2EE.
This script initiates verification and displays emojis for the user to confirm.
The user must confirm the emojis match in Element to complete verification.
Usage:
matrix-e2ee-verify.py # Auto-find device and verify
matrix-e2ee-verify.py --request DEVICE # Verify with specific device
matrix-e2ee-verify.py --list # List your devices
The script will:
1. Find another device (or use specified device)
2. Initiate verification
3. Display 7 emojis that must match Element
4. Wait for user to confirm in Element
5. Complete verification and fetch room keys
"""
import asyncio
import sys
import os
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from _lib import (
check_e2ee_dependencies,
load_config,
get_store_path,
load_credentials,
prefer_ipv4,
suppress_nio_logging,
)
# Check dependencies before importing nio
check_e2ee_dependencies()
from nio import (
AsyncClient,
AsyncClientConfig,
KeyVerificationEvent,
KeyVerificationStart,
KeyVerificationAccept,
KeyVerificationKey,
KeyVerificationMac,
KeyVerificationCancel,
ToDeviceMessage,
UnknownToDeviceEvent,
ToDeviceError,
DevicesResponse,
MegolmEvent,
RoomMessagesResponse,
)
try:
from nio import KeyVerificationRequest
except ImportError:
KeyVerificationRequest = None
sys.stdout.reconfigure(line_buffering=True)
sys.stderr.reconfigure(line_buffering=True)
class VerificationHandler:
def __init__(self, client, debug=False):
self.client = client
self.debug = debug
self.current_verification = None
self.emojis = None
self.verified = False
self.cancelled = False
self.key_sent = False
self.sas_accepted = False
def _debug(self, msg):
if self.debug:
print(f"[DEBUG] {msg}")
async def handle_raw_event(self, event):
"""Handle raw to-device events."""
if isinstance(event, UnknownToDeviceEvent) and hasattr(event, "source"):
source = event.source
if source.get("type") == "m.key.verification.request":
content = source.get("content", {})
txn_id = content.get("transaction_id")
from_device = content.get("from_device")
methods = content.get("methods", [])
sender = source.get("sender")
print(f"\nVerification request received from {from_device}")
if "m.sas.v1" in methods:
self.current_verification = txn_id
self._debug(f"Sending ready response for {txn_id}")
ready_content = {
"from_device": self.client.device_id,
"transaction_id": txn_id,
"methods": ["m.sas.v1"],
}
msg = ToDeviceMessage(
type="m.key.verification.ready",
recipient=sender,
recipient_device=from_device,
content=ready_content,
)
await self.client.to_device(msg)
print("Ready sent, waiting for emoji exchange...")
elif source.get("type", "").startswith("m.key.verification."):
self._debug(f"Verification event: {source.get('type')}")
async def handle_event(self, event):
"""Handle verification events."""
event_type = type(event).__name__
self._debug(f"Received {event_type}")
if KeyVerificationRequest and isinstance(event, KeyVerificationRequest):
print(f"\nVerification request from {event.sender}")
self.current_verification = event.transaction_id
try:
await self.client.accept_key_verification(event.transaction_id)
print("Accepted, waiting for emoji exchange...")
except Exception as e:
self._debug(f"Error accepting: {e}")
elif isinstance(event, KeyVerificationStart):
if self.sas_accepted:
return
print("Verification started")
self.current_verification = event.transaction_id
try:
await self.client.accept_key_verification(event.transaction_id)
self.sas_accepted = True
except Exception as e:
self._debug(f"Error accepting: {e}")
elif isinstance(event, KeyVerificationAccept):
print("Other device accepted")
elif isinstance(event, KeyVerificationKey):
if self.key_sent:
return
sas = self.client.key_verifications.get(event.transaction_id)
if not sas:
self._debug(f"No SAS for {event.transaction_id}")
return
try:
self.emojis = sas.get_emoji()
# Build emoji display
emoji_lines = []
emoji_lines.append("")
emoji_lines.append(
"╔══════════════════════════════════════════════════════════╗"
)
emoji_lines.append(
"║ 🔐 VERIFICATION EMOJIS - COMPARE NOW! 🔐 ║"
)
emoji_lines.append(
"╠══════════════════════════════════════════════════════════╣"
)
emoji_lines.append(
"║ ║"
)
for emoji, name in self.emojis:
line = f" {emoji} {name}"
padding = 58 - len(line)
emoji_lines.append(f"║{line}{' ' * padding}║")
emoji_lines.append(
"║ ║"
)
emoji_lines.append(
"╠══════════════════════════════════════════════════════════╣"
)
emoji_lines.append(
"║ 👆 These emojis must EXACTLY match what Element shows! ║"
)
emoji_lines.append(
"║ ║"
)
emoji_lines.append(
"║ ➡️ Go to Element now and confirm the emojis match ║"
)
emoji_lines.append(
"║ ➡️ Click 'They match' in Element to complete ║"
)
emoji_lines.append(
"╚══════════════════════════════════════════════════════════╝"
)
emoji_lines.append("")
# Write to file for agent polling (before stdout which may be buffered)
emoji_file = "/tmp/matrix_verification_emojis.txt"
with open(emoji_file, "w") as f:
f.write("\n".join(emoji_lines))
# Also print to stdout
for line in emoji_lines:
print(line)
# Share our key
key_msg = sas.share_key()
if key_msg:
await self.client.to_device(key_msg)
self.key_sent = True
# Accept our side (user confirms in Element)
sas.accept_sas()
mac_msg = sas.get_mac()
if mac_msg:
await self.client.to_device(mac_msg)
print("Waiting for you to confirm in Element...")
except Exception as e:
self._debug(f"Error in key exchange: {e}")
elif isinstance(event, KeyVerificationMac):
if self.verified:
return
sas = self.client.key_verifications.get(event.transaction_id)
if sas:
try:
sas.receive_mac_event(event)
if sas.verified:
self.verified = True
print("\n✅ VERIFICATION SUCCESSFUL!")
done_content = {"transaction_id": event.transaction_id}
done_msg = ToDeviceMessage(
type="m.key.verification.done",
recipient=event.sender,
recipient_device=sas.other_device_id
if hasattr(sas, "other_device_id")
else sas.other_olm_device.device_id,
content=done_content,
)
await self.client.to_device(done_msg)
except Exception as e:
self._debug(f"Error processing MAC: {e}")
elif isinstance(event, KeyVerificationCancel):
print(f"\n❌ Verification cancelled: {event.reason}")
self.cancelled = True
async def run_verification(
config: dict, request_device: str = None, timeout: int = 120, debug: bool = False
):
"""Run verification process."""
store_path = get_store_path()
creds = load_credentials()
if not creds or creds.get("user_id") != config["user_id"]:
if "access_token" not in config:
print(
"Error: No credentials. Run matrix-e2ee-setup.py first.",
file=sys.stderr,
)
return False
from nio import WhoamiResponse
temp_client = AsyncClient(config["homeserver"], config["user_id"])
temp_client.access_token = config["access_token"]
whoami = await temp_client.whoami()
await temp_client.close()
if isinstance(whoami, WhoamiResponse):
device_id = whoami.device_id
access_token = config["access_token"]
else:
print(f"Error: {whoami}", file=sys.stderr)
return False
else:
device_id = creds["device_id"]
access_token = creds["access_token"]
print(f"This device: {device_id}")
client_config = AsyncClientConfig(store_sync_tokens=True, encryption_enabled=True)
client = AsyncClient(
homeserver=config["homeserver"],
user=config["user_id"],
device_id=device_id,
store_path=str(store_path),
config=client_config,
)
handler = VerificationHandler(client, debug=debug)
client.add_to_device_callback(handler.handle_raw_event, UnknownToDeviceEvent)
client.add_to_device_callback(handler.handle_event, KeyVerificationEvent)
if KeyVerificationRequest:
client.add_to_device_callback(handler.handle_event, KeyVerificationRequest)
client.add_to_device_callback(handler.handle_event, KeyVerificationStart)
client.add_to_device_callback(handler.handle_event, KeyVerificationAccept)
client.add_to_device_callback(handler.handle_event, KeyVerificationKey)
client.add_to_device_callback(handler.handle_event, KeyVerificationMac)
client.add_to_device_callback(handler.handle_event, KeyVerificationCancel)
try:
client.restore_login(config["user_id"], device_id, access_token)
if client.store:
client.load_store()
if client.should_upload_keys:
if debug:
print("[DEBUG] Uploading keys...")
await client.keys_upload()
print("Syncing...")
await client.sync(timeout=10000)
# Find target device if not specified
if not request_device:
print("Finding another device to verify with...")
resp = await client.devices()
if isinstance(resp, DevicesResponse):
other_devices = [d for d in resp.devices if d.id != device_id]
if other_devices:
# Filter and prioritize devices
def device_priority(d):
name = (d.display_name or "").lower()
# Skip backup devices (can't respond interactively)
if "backup" in name:
return (4, name) # Lowest priority
# Prefer Element clients (desktop/mobile - interactive)
if "element" in name:
return (0, name) # Highest priority
# Then riot/web clients
if "riot" in name or "chrome" in name or "firefox" in name:
return (1, name)
# Named devices
if d.display_name:
return (2, name)
# Unnamed devices last
return (3, name)
sorted_devices = sorted(other_devices, key=device_priority)
target = sorted_devices[0]
request_device = target.id
print(
f"Target device: {target.display_name or target.id} ({target.id})"
)
else:
print("No other devices found!", file=sys.stderr)
print("Open Element on another device first.", file=sys.stderr)
return False
# Initiate verification
print(f"\nInitiating verification with {request_device}...")
# Query keys first
try:
await client.keys_query()
except Exception as e:
if debug:
print(f"[DEBUG] Keys query: {e}")
# Try to find device in store
user_id = config["user_id"]
if user_id in client.device_store:
for dev_id, _device in client.device_store[user_id].items():
if dev_id == request_device:
break
# Send verification request
import secrets
import time
txn_id = secrets.token_hex(16)
handler.current_verification = txn_id
request_content = {
"from_device": device_id,
"transaction_id": txn_id,
"methods": ["m.sas.v1"],
"timestamp": int(time.time() * 1000),
}
msg = ToDeviceMessage(
type="m.key.verification.request",
recipient=user_id,
recipient_device=request_device,
content=request_content,
)
resp = await client.to_device(msg)
if isinstance(resp, ToDeviceError):
print(f"Error: {resp}", file=sys.stderr)
return False
print("Verification request sent!")
print("\n📱 Check Element for the verification popup")
print(f" Timeout: {timeout} seconds\n")
# Wait for verification
start_time = asyncio.get_event_loop().time()
while not handler.verified and not handler.cancelled:
elapsed = asyncio.get_event_loop().time() - start_time
if elapsed > timeout:
print("\n⏰ Timeout waiting for verification.")
return False
await client.sync(timeout=5000)
# Post-verification: fetch room keys
if handler.verified:
print("\n📦 Fetching room keys from verified devices...")
rooms_checked = 0
for room_id, room in list(client.rooms.items())[:10]:
if room.encrypted:
rooms_checked += 1
try:
result = await client.room_messages(room_id, start="", limit=50)
if isinstance(result, RoomMessagesResponse):
for event in result.chunk:
if isinstance(event, MegolmEvent):
try:
await client.request_room_key(event)
except Exception:
pass
except Exception:
pass
print(f" Checked {rooms_checked} encrypted rooms")
print(" Waiting for keys (30s)...")
for i in range(6):
await client.sync(timeout=5000)
if (i + 1) % 2 == 0:
print(f" ... {(i + 1) * 5}s")
print("\n🎉 Device verified and keys synced!")
print(" Use matrix-fetch-keys.py ROOM for additional keys")
return handler.verified
finally:
await client.close()
async def list_devices(config: dict) -> list:
"""List all devices for the current user."""
store_path = get_store_path()
creds = load_credentials()
if creds and creds.get("user_id") == config["user_id"]:
device_id = creds["device_id"]
access_token = creds["access_token"]
elif "access_token" in config:
access_token = config["access_token"]
from nio import WhoamiResponse
temp_client = AsyncClient(config["homeserver"], config["user_id"])
temp_client.access_token = access_token
whoami = await temp_client.whoami()
await temp_client.close()
if isinstance(whoami, WhoamiResponse):
device_id = whoami.device_id
else:
return []
else:
return []
client_config = AsyncClientConfig(store_sync_tokens=True, encryption_enabled=True)
client = AsyncClient(
homeserver=config["homeserver"],
user=config["user_id"],
device_id=device_id,
store_path=str(store_path),
config=client_config,
)
try:
client.restore_login(config["user_id"], device_id, access_token)
resp = await client.devices()
if isinstance(resp, DevicesResponse):
devices = []
for d in resp.devices:
devices.append(
{
"device_id": d.id,
"display_name": d.display_name or "No name",
"is_current": d.id == device_id,
}
)
return devices
return []
finally:
await client.close()
def main():
import argparse
parser = argparse.ArgumentParser(
description="Verify this device with another device using emoji verification",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
Examples:
%(prog)s # Auto-find device and start verification
%(prog)s --request DEVICE # Verify with specific device
%(prog)s --list # List all your devices
The script will display 7 emojis that must match what Element shows.
Confirm the match in Element to complete verification.
""",
)
parser.add_argument("--request", metavar="DEVICE", help="Target specific device ID")
parser.add_argument("--list", action="store_true", help="List all your devices")
parser.add_argument(
"--timeout", type=int, default=120, help="Timeout in seconds (default: 120)"
)
parser.add_argument("--debug", action="store_true", help="Enable debug output")
args = parser.parse_args()
prefer_ipv4()
if not args.debug:
suppress_nio_logging()
config = load_config(require_user_id=True)
if args.list:
devices = asyncio.run(list_devices(config))
if not devices:
print("No devices found or error")
sys.exit(1)
print("Your devices:")
for d in devices:
marker = " ← this device" if d["is_current"] else ""
print(f" {d['device_id']}: {d['display_name']}{marker}")
sys.exit(0)
success = asyncio.run(
run_verification(
config=config,
request_device=args.request,
timeout=args.timeout,
debug=args.debug,
)
)
sys.exit(0 if success else 1)
if __name__ == "__main__":
main()
#!/usr/bin/env python3
# /// script
# requires-python = ">=3.10"
# dependencies = []
# ///
"""React to a Matrix message with an emoji.
Usage:
matrix-react.py ROOM EVENT_ID EMOJI
matrix-react.py --help
Arguments:
ROOM Room alias (#room:server), room ID (!id:server), or room name
EVENT_ID Event ID of message to react to (e.g., $abc123...)
EMOJI Emoji reaction (e.g., checkmark, thumbsup, party)
Options:
--json Output as JSON
--quiet Minimal output
--debug Show debug information
--help Show this help
Examples:
# Add checkmark reaction
matrix-react.py "#ops:matrix.org" "$eventid" "checkmark"
# Thumbs up
matrix-react.py "#dev:matrix.org" "$eventid" "thumbsup"
"""
import json
import sys
import os
import time
# Add script directory to path for _lib imports
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from _lib import (
load_config,
matrix_request,
resolve_room_alias,
find_room_by_name,
clean_message,
)
sys.stdout.reconfigure(line_buffering=True)
sys.stderr.reconfigure(line_buffering=True)
def send_reaction(config: dict, room_id: str, event_id: str, emoji: str) -> dict:
"""Send a reaction to a message.
Reactions use the m.reaction event type with m.annotation relation.
"""
txn_id = str(int(time.time() * 1000))
content = {
"m.relates_to": {"rel_type": "m.annotation", "event_id": event_id, "key": emoji}
}
return matrix_request(
config, "PUT", f"/rooms/{room_id}/send/m.reaction/{txn_id}", content
)
def main():
import argparse
parser = argparse.ArgumentParser(
description="React to a Matrix message with an emoji"
)
parser.add_argument(
"room", help="Room alias (#room:server), room ID (!id:server), or room name"
)
parser.add_argument("event_id", help="Event ID of message to react to")
parser.add_argument(
"emoji", help="Emoji reaction (e.g., checkmark, thumbsup, party)"
)
parser.add_argument("--json", action="store_true", help="Output as JSON")
parser.add_argument("--quiet", "-q", action="store_true", help="Minimal output")
parser.add_argument("--debug", action="store_true", help="Show debug info")
args = parser.parse_args()
config = load_config()
# Clean and resolve room
room_input = clean_message(args.room)
room_id = room_input
if room_input.startswith("!"):
# Direct room ID
room_id = room_input
if args.debug:
print(f"Using room ID directly: {room_id}", file=sys.stderr)
elif room_input.startswith("#"):
# Room alias
try:
room_id = resolve_room_alias(config, room_input)
if args.debug:
print(f"Resolved {room_input} -> {room_id}", file=sys.stderr)
except ValueError as e:
if args.json:
print(json.dumps({"error": str(e)}))
else:
print(f"Error: {e}", file=sys.stderr)
sys.exit(1)
else:
# Room name lookup
found_id, matches = find_room_by_name(config, room_input)
if found_id:
room_id = found_id
if args.debug:
print(f"Found room: {room_id}", file=sys.stderr)
else:
error_msg = f"Could not find room '{room_input}'"
if matches:
error_msg += ". Multiple matches found:\n"
for m in matches:
alias_str = f" ({m['alias']})" if m.get("alias") else ""
error_msg += f" - {m['name']}{alias_str}: {m['room_id']}\n"
else:
error_msg += ". Use 'matrix-rooms.py' to list available rooms."
if args.json:
print(json.dumps({"error": error_msg}))
else:
print(f"Error: {error_msg}", file=sys.stderr)
sys.exit(1)
# Send reaction
result = send_reaction(config, room_id, args.event_id, args.emoji)
if "error" in result:
if args.json:
print(json.dumps(result))
else:
print(f"Error: {result['error']}", file=sys.stderr)
sys.exit(1)
if args.json:
print(json.dumps(result))
elif args.quiet:
print(result.get("event_id", ""))
else:
print(f"Reacted with {args.emoji} to {args.event_id}")
print(f"Event ID: {result.get('event_id')}")
if __name__ == "__main__":
main()