Add Matrix intake bot (M1+M2): typed message → approved fundraising-grid write
New backend/matrix_intake/ runs as its own process (matrix-nio isolated from the stdlib CRM): local-Qwen parse via Spark Control → in-thread human approval (yes/edit/no) → write through the CRM's own log-communication endpoint, tagged source=matrix_intake. Adds read-only GET /api/intake/match (returns grid row id, no-duplicate contract); threads provenance through handle_log_fundraising_communication. Reviewer-passed: pop-before-commit closes a double-approve race; edit-grammar fix. Text-only v1; business-card photo (M3) deferred (no Spark vision model). 26/26 tests green; live Matrix smoke pending deploy.
This commit is contained in:
@@ -0,0 +1,55 @@
|
||||
"""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
|
||||
Reference in New Issue
Block a user