Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions docs/design-notes.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,26 @@ third tier (chat-triggered rebuild) is a card.
bot-runner service concept (one consumer), bot-runner image reuse
(the curator uses 2 of its 10 deps; slim image won).

## Both halves of a Matrix relation (thread corrections, 2026-08-02)

The archivist learned to *answer* in a thread (`_answer`, `REPLY_IN_THREAD`)
but kept *reading* one `m.in_reply_to` hop, so a correction typed in a
filing's thread was routed to search. A client's reply pointer inside a
thread is a falling-back rendering aid at the newest event there, not the
message the human aimed at, so the pointer stopped naming our filing the
moment anything followed it (a todo link, a status line).

The general shape, worth checking whenever a bot gains a new relation
type: **whoever teaches the sender a relation owns teaching the reader
the same one.** Half a relation is worse than none, because the send
side looks right in the client while routing silently degrades.

Also why it survived a release: the intent spec that would have caught it
(`tests/integration/test_room_modes_e2e.py`) is marked
`xfail(strict=False)`, which is green when broken and green when fixed.
A non-strict xfail is not coverage. Making it `strict=True`, skipping it
with a reason, or deleting it are all better; the action is FAM-6.

## Surviving upstream drift: the `wait_task` pattern

When Paperless-ngx 3.0 redesigned its task API, the fix that held up was
Expand Down
2 changes: 2 additions & 0 deletions docs/user-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,8 @@ Reply to the bot's filing confirmation with what's off:

The archivist re-reads the document with your correction, re-files it, and confirms again. Corrections chain: if the second attempt is still off, reply to the new confirmation. Each reply carries the whole conversation back to the original document, so you never start over.

Anything you write in the filing's thread counts as a correction, so you can also just type in the thread the bot answered in, or reply to your own original message. You do not have to quote the confirmation.

This works for captures too: reply to a bookmark or note confirmation and it gets re-filed with your hint.

---
Expand Down
46 changes: 46 additions & 0 deletions stacklets/core/bot-runner/microbot.py
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@
RoomMessagesResponse,
SyncResponse,
)
from nio.api import RelationshipType

from room_context import RoomContext, context_for

Expand Down Expand Up @@ -786,6 +787,51 @@ async def _reply_parent_envelope(self, room_id: str, event) -> dict | None:
envelope = parent.source.get("content", {}).get(self.FAMSTACK_EVENT_KEY)
return envelope if isinstance(envelope, dict) else None

async def _thread_envelopes(
self, room_id: str, root_event_id: str, *, limit: int = 10,
) -> list[tuple[str, dict]]:
"""Our own famstack envelopes in the thread rooted at
``root_event_id``, newest first.

The threaded sibling of ``_reply_parent_envelope``. Someone
writing inside a thread is answering what the thread holds, and
their client's ``m.in_reply_to`` does not say which message that
is: for a threaded message the relation is a *falling back*
pointer at the newest event in the thread (Matrix v1.4), there
so thread-blind clients still render some context. Following it
lands on whatever we happened to post last. So we ask the
homeserver for the thread's children instead — Matrix is the
ledger, the bot keeps no thread bookkeeping — and hand back
every envelope we ourselves posted there.

Newest first, because a bot's later message in a thread
supersedes its earlier one. Bounded to ``limit`` events
examined so one chat message can never turn into unbounded API
calls. Returns ``(event_id, envelope)`` pairs; as with the
single-hop sibling, the caller decides what an envelope
*means*. An id that roots no thread simply has none.
"""
envelopes: list[tuple[str, dict]] = []
try:
examined = 0
async for related in self._client.room_get_event_relations(
room_id, root_event_id, RelationshipType.thread,
):
examined += 1
if examined > limit:
break
if getattr(related, "sender", None) != self.user_id:
continue
content = (getattr(related, "source", None) or {}).get("content", {})
envelope = content.get(self.FAMSTACK_EVENT_KEY)
event_id = getattr(related, "event_id", None)
if isinstance(envelope, dict) and event_id:
envelopes.append((event_id, envelope))
except Exception as e:
logger.debug("[{}] thread relations fetch failed for {}: {}",
self.name, root_event_id, e)
return envelopes

def _ensure_http(self) -> aiohttp.ClientSession:
"""The shared aiohttp session, created on first use.

Expand Down
4 changes: 3 additions & 1 deletion stacklets/core/bot-runner/requirements.txt
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
matrix-nio[e2e]>=0.24,<1.0
# 0.25 is the floor: reading a thread's events (`room_get_event_relations`,
# how the bot finds the filing a correction belongs to) arrived there.
matrix-nio[e2e]>=0.25,<1.0
aiohttp>=3.9,<4.0
loguru>=0.7,<1.0
Pillow>=10.0,<12.0
Expand Down
Loading
Loading