aefb2aa119
Four bot-side UX fixes surfaced by the live smoke: - Post a brief pointer in the main timeline (a reply to the user's message) alongside the in-thread proposal card, so proposals aren't missed inside a thread. Pointer only — approvals still happen in the thread, where the note is visible (you can't make an informed yes/no without seeing it). - A bare yes/no typed in the main timeline while a proposal is pending now gets a "reply in the thread" redirect instead of "couldn't tell what to record." - Clearer commit confirmations: "Created a new grid entry for X" vs "Logged a note on X (existing grid entry)." - Send a blank communication subject when a note is present so the grid's one-line note summary shows the note text, not the "(Matrix)" label (provenance stays in source="matrix_intake").
74 lines
2.7 KiB
Python
74 lines
2.7 KiB
Python
"""Matrix plumbing lifted from matrix-bridge (src/bot.py): message splitting, thread-root
|
|
detection, and a threaded-reply sender. Kept dependency-light so the rest of the bot is
|
|
testable without a live homeserver."""
|
|
|
|
MAX_MSG_CHARS = 30000 # well under Matrix's ~64KB event cap
|
|
|
|
|
|
def split_message(text, limit=MAX_MSG_CHARS):
|
|
"""Split text into <=limit-char chunks on newline boundaries (no truncation)."""
|
|
if len(text) <= limit:
|
|
return [text]
|
|
chunks, buf = [], ""
|
|
for line in text.splitlines(keepends=True):
|
|
while len(line) > limit:
|
|
if buf:
|
|
chunks.append(buf)
|
|
buf = ""
|
|
chunks.append(line[:limit])
|
|
line = line[limit:]
|
|
if len(buf) + len(line) > limit:
|
|
chunks.append(buf)
|
|
buf = ""
|
|
buf += line
|
|
if buf:
|
|
chunks.append(buf)
|
|
return chunks
|
|
|
|
|
|
def thread_root_of(event):
|
|
"""Return the thread root event_id if this message is a threaded reply, else None."""
|
|
relates = (getattr(event, "source", None) or {}).get("content", {}).get("m.relates_to") or {}
|
|
if relates.get("rel_type") == "m.thread":
|
|
return relates.get("event_id")
|
|
return None
|
|
|
|
|
|
def thread_content(text, thread_root):
|
|
"""Build an m.room.message content dict, threaded under thread_root when given."""
|
|
content = {"msgtype": "m.text", "body": text}
|
|
if thread_root:
|
|
content["m.relates_to"] = {
|
|
"rel_type": "m.thread",
|
|
"event_id": thread_root,
|
|
"is_falling_back": True,
|
|
"m.in_reply_to": {"event_id": thread_root},
|
|
}
|
|
return content
|
|
|
|
|
|
def make_say(client):
|
|
"""Return an async say(room_id, text, thread_root=None) bound to a matrix-nio client."""
|
|
async def say(room_id, text, thread_root=None):
|
|
for chunk in split_message(text):
|
|
await client.room_send(room_id, "m.room.message", thread_content(chunk, thread_root))
|
|
return say
|
|
|
|
|
|
def reply_content(text, reply_to_event_id):
|
|
"""Build a plain (non-threaded) reply: shows in the MAIN timeline as a reply to
|
|
reply_to_event_id, unlike thread_content() which lands the message inside a thread."""
|
|
content = {"msgtype": "m.text", "body": text}
|
|
if reply_to_event_id:
|
|
content["m.relates_to"] = {"m.in_reply_to": {"event_id": reply_to_event_id}}
|
|
return content
|
|
|
|
|
|
def make_reply(client):
|
|
"""Return an async reply(room_id, text, reply_to) that posts a plain main-timeline reply —
|
|
the brief 'proposed X — see thread' nudge alongside the in-thread proposal card."""
|
|
async def reply(room_id, text, reply_to):
|
|
for chunk in split_message(text):
|
|
await client.room_send(room_id, "m.room.message", reply_content(chunk, reply_to))
|
|
return reply
|