Skip to content

Commit 4aa3dda

Browse files
author
Netresearch
committed
feat: add message editing support
- Add matrix-edit.py to modify existing messages - Uses m.replace relation type per Matrix spec - Supports markdown formatting in edits - Bump version to 1.5.0
1 parent fc3d00b commit 4aa3dda

4 files changed

Lines changed: 286 additions & 1 deletion

File tree

.claude-plugin/plugin.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "matrix-communication",
3-
"version": "1.4.2",
3+
"version": "1.5.0",
44
"description": "Agentic Skill for Matrix chat communication. Send messages to Matrix rooms on behalf of users via access token authentication. Works with any Matrix homeserver.",
55
"author": {
66
"name": "Netresearch DTT GmbH",

README.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@ This skill enables AI coding agents to send messages to Matrix chat rooms on beh
1919
- **Emotes** - `/me` style action messages (`--emote`)
2020
- **Thread replies** - Keep discussions organized (`--thread`)
2121
- **Reactions** - Add emoji reactions to messages (✅ 👍 🚀)
22+
- **Edit messages** - Modify sent messages
23+
- **Redact messages** - Delete messages from rooms
2224
- **Visual effects** - Confetti 🎉, fireworks 🎆, snowfall ❄️ (Element clients)
2325
- **List rooms** to find the right destination
2426
- **Read messages** - both unencrypted and E2EE decryption
@@ -155,6 +157,7 @@ matrix-skill/
155157
│ ├── matrix-rooms.py # List joined rooms
156158
│ ├── matrix-resolve.py # Resolve room aliases
157159
│ ├── matrix-react.py # React to messages
160+
│ ├── matrix-edit.py # Edit existing messages
158161
│ ├── matrix-redact.py # Delete/redact messages
159162
│ ├── matrix-e2ee-setup.py # E2EE device setup
160163
│ └── matrix-e2ee-verify.py # Device verification

scripts/matrix-edit.py

Lines changed: 281 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,281 @@
1+
#!/usr/bin/env python3
2+
"""Edit an existing message in a Matrix room.
3+
4+
Usage:
5+
matrix-edit.py ROOM EVENT_ID NEW_MESSAGE
6+
matrix-edit.py --help
7+
8+
Arguments:
9+
ROOM Room alias (#room:server) or room ID (!id:server)
10+
EVENT_ID Event ID of the message to edit ($xxx:server)
11+
NEW_MESSAGE The new message content (replaces original)
12+
13+
Options:
14+
--json Output as JSON
15+
--quiet Minimal output
16+
--debug Show debug information
17+
--help Show this help
18+
"""
19+
20+
import json
21+
import re
22+
import sys
23+
import time
24+
import urllib.request
25+
import urllib.error
26+
import urllib.parse
27+
from pathlib import Path
28+
29+
30+
def load_config() -> dict:
31+
"""Load Matrix config from ~/.config/matrix/config.json"""
32+
config_path = Path.home() / ".config" / "matrix" / "config.json"
33+
if not config_path.exists():
34+
print(f"Error: Config file not found: {config_path}", file=sys.stderr)
35+
sys.exit(1)
36+
37+
with open(config_path) as f:
38+
config = json.load(f)
39+
40+
# Add bot_prefix handling
41+
return config
42+
43+
44+
def matrix_request(config: dict, method: str, endpoint: str, data: dict = None) -> dict:
45+
"""Make a Matrix API request."""
46+
url = f"{config['homeserver']}/_matrix/client/v3{endpoint}"
47+
headers = {
48+
"Authorization": f"Bearer {config['access_token']}",
49+
"Content-Type": "application/json"
50+
}
51+
52+
body = json.dumps(data).encode() if data else None
53+
req = urllib.request.Request(url, data=body, headers=headers, method=method)
54+
55+
try:
56+
with urllib.request.urlopen(req) as response:
57+
return json.loads(response.read().decode())
58+
except urllib.error.HTTPError as e:
59+
error_body = e.read().decode()
60+
try:
61+
error_json = json.loads(error_body)
62+
return {"error": error_json.get("error", error_body), "errcode": error_json.get("errcode")}
63+
except:
64+
return {"error": error_body, "errcode": str(e.code)}
65+
66+
67+
def resolve_room_alias(config: dict, alias: str) -> str:
68+
"""Resolve a room alias to room ID."""
69+
encoded_alias = urllib.parse.quote(alias, safe='')
70+
result = matrix_request(config, "GET", f"/directory/room/{encoded_alias}")
71+
if "room_id" in result:
72+
return result["room_id"]
73+
raise ValueError(f"Could not resolve room alias: {result.get('error', 'Unknown error')}")
74+
75+
76+
def shorten_service_urls(text: str) -> str:
77+
"""Convert service URLs to shorter linked text."""
78+
text = re.sub(
79+
r'https?://[^/]+/browse/([A-Z][A-Z0-9]+-\d+)',
80+
r'[\1](https://\g<0>)',
81+
text
82+
)
83+
text = re.sub(r'\(https://https?://', r'(https://', text)
84+
text = re.sub(
85+
r'https?://github\.com/([^/]+)/([^/]+)/(issues|pull)/(\d+)',
86+
r'[\1/\2#\4](\g<0>)',
87+
text
88+
)
89+
text = re.sub(
90+
r'https?://github\.com/([^/]+)/([^/]+)/commit/([a-f0-9]{7,40})',
91+
r'[\1/\2@\3](\g<0>)',
92+
text
93+
)
94+
text = re.sub(
95+
r'https?://[^/]+/([^/]+/[^/]+)/-/(issues|merge_requests)/(\d+)',
96+
r'[\1#\3](\g<0>)',
97+
text
98+
)
99+
return text
100+
101+
102+
def markdown_to_html(text: str) -> str:
103+
"""Convert markdown to Matrix HTML."""
104+
html = shorten_service_urls(text)
105+
106+
code_blocks = []
107+
def save_code_block(match):
108+
lang = match.group(1) or ''
109+
code = match.group(2)
110+
idx = len(code_blocks)
111+
if lang:
112+
code_blocks.append(f'<pre><code class="language-{lang}">{code}</code></pre>')
113+
else:
114+
code_blocks.append(f'<pre><code>{code}</code></pre>')
115+
return f'{{{{CODEBLOCK_{idx}}}}}'
116+
117+
html = re.sub(r'```(\w*)\n(.*?)```', save_code_block, html, flags=re.DOTALL)
118+
html = re.sub(r'\|\|(.+?)\|\|', r'<span data-mx-spoiler>\1</span>', html)
119+
html = re.sub(r'\[([^\]]+)\]\(([^)]+)\)', r'<a href="\2">\1</a>', html)
120+
html = re.sub(
121+
r'(?<!["\'/])(@[a-zA-Z0-9._=-]+:[a-zA-Z0-9.-]+\.[a-zA-Z]{2,})',
122+
r'<a href="https://matrix.to/#/\1">\1</a>',
123+
html
124+
)
125+
html = re.sub(
126+
r'(?<!["\'/])(#[a-zA-Z0-9._=-]+:[a-zA-Z0-9.-]+\.[a-zA-Z]{2,})',
127+
r'<a href="https://matrix.to/#/\1">\1</a>',
128+
html
129+
)
130+
html = re.sub(r'~~(.+?)~~', r'<del>\1</del>', html)
131+
html = re.sub(r'\*\*(.+?)\*\*', r'<strong>\1</strong>', html)
132+
html = re.sub(r'\*(.+?)\*', r'<em>\1</em>', html)
133+
html = re.sub(r'`(.+?)`', r'<code>\1</code>', html)
134+
html = re.sub(r'\n{2,}', '\n', html)
135+
136+
lines = html.split('\n')
137+
in_list = False
138+
in_quote = False
139+
result = []
140+
141+
for line in lines:
142+
stripped = line.strip()
143+
if stripped.startswith('> '):
144+
if not in_quote:
145+
if in_list:
146+
result.append('</ul>')
147+
in_list = False
148+
result.append('<blockquote>')
149+
in_quote = True
150+
result.append(stripped[2:])
151+
elif stripped.startswith('- '):
152+
if in_quote:
153+
result.append('</blockquote>')
154+
in_quote = False
155+
if not in_list:
156+
result.append('<ul>')
157+
in_list = True
158+
result.append(f'<li>{stripped[2:]}</li>')
159+
elif stripped == '':
160+
if in_quote:
161+
result.append('</blockquote>')
162+
in_quote = False
163+
continue
164+
else:
165+
if in_quote:
166+
result.append('</blockquote>')
167+
in_quote = False
168+
if in_list:
169+
result.append('</ul>')
170+
in_list = False
171+
result.append(line)
172+
173+
if in_quote:
174+
result.append('</blockquote>')
175+
if in_list:
176+
result.append('</ul>')
177+
178+
html = '{{BR}}'.join(result)
179+
html = re.sub(r'\{\{BR\}\}(?=<ul>|<li>|</ul>|</li>|<blockquote>|</blockquote>|<pre>)', '', html)
180+
html = re.sub(r'(</ul>|</li>|</blockquote>|</pre>)\{\{BR\}\}', r'\1', html)
181+
html = re.sub(r'(<blockquote>)\{\{BR\}\}', r'\1', html)
182+
html = html.replace('{{BR}}', '<br>')
183+
184+
for idx, block in enumerate(code_blocks):
185+
html = html.replace(f'{{{{CODEBLOCK_{idx}}}}}', block)
186+
187+
return html
188+
189+
190+
def clean_message(message: str) -> str:
191+
"""Clean message from bash escaping artifacts."""
192+
return message.replace('\\!', '!')
193+
194+
195+
def edit_message(config: dict, room_id: str, event_id: str, new_message: str) -> dict:
196+
"""Edit an existing message in a Matrix room."""
197+
txn_id = str(int(time.time() * 1000))
198+
199+
# Build the replacement content
200+
content = {
201+
"msgtype": "m.text",
202+
"body": f"* {new_message}", # Prefix with * for fallback
203+
"m.new_content": {
204+
"msgtype": "m.text",
205+
"body": new_message,
206+
},
207+
"m.relates_to": {
208+
"rel_type": "m.replace",
209+
"event_id": event_id,
210+
}
211+
}
212+
213+
# Add HTML formatting
214+
html = markdown_to_html(new_message)
215+
if html != new_message:
216+
content["format"] = "org.matrix.custom.html"
217+
content["formatted_body"] = f"* {html}"
218+
content["m.new_content"]["format"] = "org.matrix.custom.html"
219+
content["m.new_content"]["formatted_body"] = html
220+
221+
return matrix_request(
222+
config,
223+
"PUT",
224+
f"/rooms/{urllib.parse.quote(room_id, safe='')}/send/m.room.message/{txn_id}",
225+
content
226+
)
227+
228+
229+
def main():
230+
import argparse
231+
232+
parser = argparse.ArgumentParser(description="Edit a message in a Matrix room")
233+
parser.add_argument("room", help="Room alias (#room:server) or room ID (!id:server)")
234+
parser.add_argument("event_id", help="Event ID of the message to edit")
235+
parser.add_argument("message", help="New message content")
236+
parser.add_argument("--json", action="store_true", help="Output as JSON")
237+
parser.add_argument("--quiet", "-q", action="store_true", help="Minimal output")
238+
parser.add_argument("--debug", action="store_true", help="Show debug info")
239+
240+
args = parser.parse_args()
241+
242+
config = load_config()
243+
244+
# Clean message
245+
message = clean_message(args.message)
246+
247+
# Resolve room alias if needed
248+
room_id = args.room
249+
if args.room.startswith("#"):
250+
try:
251+
room_id = resolve_room_alias(config, args.room)
252+
if args.debug:
253+
print(f"Resolved {args.room} -> {room_id}", file=sys.stderr)
254+
except ValueError as e:
255+
if args.json:
256+
print(json.dumps({"error": str(e)}))
257+
else:
258+
print(f"Error: {e}", file=sys.stderr)
259+
sys.exit(1)
260+
261+
# Edit message
262+
result = edit_message(config, room_id, args.event_id, message)
263+
264+
if "error" in result:
265+
if args.json:
266+
print(json.dumps(result))
267+
else:
268+
print(f"Error: {result['error']}", file=sys.stderr)
269+
sys.exit(1)
270+
271+
if args.json:
272+
print(json.dumps(result))
273+
elif args.quiet:
274+
print(result.get("event_id", ""))
275+
else:
276+
print(f"Message edited in {args.room}")
277+
print(f"Edit event ID: {result.get('event_id')}")
278+
279+
280+
if __name__ == "__main__":
281+
main()

skills/matrix-communication/SKILL.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ All scripts are in the `scripts/` directory. Run with `uv run`.
5151
| `matrix-e2ee-setup.py` | One-time E2EE device setup |
5252
| `matrix-e2ee-verify.py` | Device verification (experimental) |
5353
| `matrix-react.py` | React to a message with emoji |
54+
| `matrix-edit.py` | Edit an existing message |
5455
| `matrix-redact.py` | Delete/redact a message |
5556
| `matrix-rooms.py` | List joined rooms |
5657
| `matrix-read.py` | Read recent messages (unencrypted only) |

0 commit comments

Comments
 (0)