From 35b967b507b494e02f103a00f93ff2d61c7c526a Mon Sep 17 00:00:00 2001 From: Padreug Date: Mon, 14 Sep 2026 17:01:33 +0200 Subject: [PATCH] feat(journal): edits update the entry in place instead of duplicating MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Editing a `!journal` message now rewrites the entry it created rather than recording another row. Entries are keyed on their source event id (new `event_id` column, v2 migration); get_edit() yields that original id, so the edit is matched back to its row. `ts` keeps the original send time — it records when the work was logged, not when the wording was fixed. Confirmation is a 📝 reaction on the edited message, because replying to an edit event renders as "This event could not be displayed" in Element and a reply per save is noise. Supersedes 3dc7eab, which ignored edits outright; updating is what's actually wanted. Entries predating this have event_id IS NULL and can't be matched, so editing one is a no-op. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_019wNB1sgkcBGijWjKTjtwG8 --- journal/README.md | 44 +++++++++++++++++++-------------- journal/journal.py | 60 ++++++++++++++++++++++++++++++++++++--------- journal/maubot.yaml | 2 +- 3 files changed, 75 insertions(+), 31 deletions(-) diff --git a/journal/README.md b/journal/README.md index 79b539c..ae41088 100644 --- a/journal/README.md +++ b/journal/README.md @@ -40,10 +40,12 @@ CREATE TABLE entries ( user TEXT NOT NULL, -- @sender:domain room TEXT NOT NULL, -- !roomid:domain ts BIGINT NOT NULL, -- ms since epoch (from evt.timestamp) - text TEXT NOT NULL -- raw entry body + text TEXT NOT NULL, -- raw entry body + event_id TEXT -- source event, so edits update in place (v2) ); CREATE INDEX entries_user_ts ON entries (user, ts DESC); CREATE INDEX entries_ts ON entries (ts DESC); +CREATE INDEX entries_event_id ON entries (event_id); ``` Wipe data via the maubot UI's per-instance **Database** tab: @@ -58,26 +60,32 @@ rather keep IDs monotonic across resets.) ## Known quirks -- **Editing a recorded entry does nothing.** Edits are deliberately - ignored (since v0.2.1), so fixing a typo in a `!journal` message - leaves the stored entry unchanged. Send a fresh `!journal` message - instead of editing. +- **Editing a `!journal` message updates its entry** (since v0.3.0). + Fix a typo, add a line, and the stored entry changes in place — the + bot confirms with a 📝 reaction on your message rather than posting + a reply. The original send time is kept, since `ts` records when the + work was logged, not when the wording was corrected. - This bullet previously claimed the opposite — that edits can't reach - the bot at all. That was wrong, and the bug it hid was noisy: mautrix - swaps an edit's content for `m.new_content` before the plugin sees it + This works because entries are keyed on the source event id, added in + the v2 migration. Entries recorded before v0.3.0 have `event_id IS + NULL` and so can't be matched — editing one of those is a no-op. + + Worth knowing if you write other maubot plugins: mautrix swaps an + edit's content for `m.new_content` before the handler sees it (`mautrix/types/event/message.py:393-396`), stripping the `* ` - fallback prefix. The handler therefore received something identical - to a brand-new command and recorded a **duplicate entry on every - edit** — one journal message edited five times produced five rows. - Neither `@command.passive` nor `@command.new` filters edits, so any - maubot plugin matching on message bodies needs its own - `if evt.content.get_edit(): return` guard. + fallback prefix, so an edit is **indistinguishable from a new + command**. Neither `@command.passive` nor `@command.new` filters + them. A plugin that doesn't call `evt.content.get_edit()` will + silently record a duplicate on every edit — which is exactly what + this plugin did before v0.3.0. + +- **Reactions never trigger the bot.** `@command.passive` registers on + `EventType.ROOM_MESSAGE` and filters `msgtype` to `(m.text,)`, so an + `m.reaction` fails both checks. If a "Logged for" appears right after + someone reacts, the trigger was an edit landing at the same moment — + a reply to an edit event renders as "This event could not be + displayed" in Element, which makes it look unrelated to any message. -- **Reactions are harmless.** They arrive as `m.reaction`, and - `@command.passive` only registers on `EventType.ROOM_MESSAGE` with - `msgtype` in `(m.text,)`, so a 👍 on a `!journal` message can never - trigger a recording. - **`!journal show ` runs the show query with that text as the user filter.** If it doesn't match any MXID, you get "No entries." Use a fully-qualified MXID like `@pat:ariege.io`. diff --git a/journal/journal.py b/journal/journal.py index 96dff19..63098dd 100644 --- a/journal/journal.py +++ b/journal/journal.py @@ -25,6 +25,12 @@ async def upgrade_v1(conn: Connection) -> None: await conn.execute("CREATE INDEX entries_ts ON entries (ts DESC)") +@upgrade_table.register(description="Track source event id so edits can update entries") +async def upgrade_v2(conn: Connection) -> None: + await conn.execute("ALTER TABLE entries ADD COLUMN event_id TEXT") + await conn.execute("CREATE INDEX entries_event_id ON entries (event_id)") + + # Match `!journal` followed by any whitespace (space, tab, OR newline) # and capture everything after. Maubot's @command.new parser only treats # *space* as the command/args delimiter, so `!journal\n` gets @@ -58,19 +64,20 @@ class JournalBot(Plugin): @command.passive(regex=_JOURNAL_RE) async def journal(self, evt: MessageEvent, match) -> None: - # Ignore edits. An edit arrives as a *fresh* m.room.message whose - # content mautrix transparently replaces with `m.new_content` - # (mautrix/types/event/message.py:393-396), stripping the "* " - # fallback prefix. The result is byte-identical to a new command, - # so every edit of a `!journal ...` message would record another - # duplicate entry. The relation survives that swap, which is what - # get_edit() reads. Neither @command.passive nor @command.new - # filters edits, so plugins must do it themselves. - if evt.content.get_edit(): - return - rest = (match[1] or "").strip() + # An edit arrives as a *fresh* m.room.message whose content mautrix + # swaps for `m.new_content` before we see it + # (mautrix/types/event/message.py:393-396), stripping the "* " + # fallback prefix — so it is indistinguishable from a new command + # and used to record a duplicate row per edit. get_edit() returns + # the *original* event id, which is what entries are keyed on, so + # the edit updates that row in place instead. + edit_of = evt.content.get_edit() + if edit_of: + await self._apply_edit(evt, edit_of, rest) + return + if not rest: await evt.reply(_USAGE) return @@ -112,10 +119,39 @@ class JournalBot(Plugin): # Default: record the full rest (multi-line preserved) await self.database.execute( - "INSERT INTO entries (user, room, ts, text) VALUES ($1, $2, $3, $4)", + "INSERT INTO entries (user, room, ts, text, event_id)" + " VALUES ($1, $2, $3, $4, $5)", evt.sender, evt.room_id, evt.timestamp, rest, + evt.event_id, ) await evt.reply(f"📓 Logged for {evt.sender}.") + + async def _apply_edit(self, evt: MessageEvent, original_id, rest: str) -> None: + """Apply an edit of a previously recorded `!journal` message.""" + row = await self.database.fetchrow( + "SELECT id FROM entries WHERE event_id = $1", original_id + ) + if row is None: + # Edit of a message that never became an entry: a `show`/`today` + # query, or an entry recorded before v0.3.0 started tracking + # event_id. Nothing to update, and re-recording would duplicate. + return + if not rest: + # Editing the body away would blank the entry; leave it alone. + return + + # `ts` deliberately keeps the original send time — it records when + # the work was logged, not when a typo was fixed. + await self.database.execute( + "UPDATE entries SET text = $1 WHERE event_id = $2", rest, original_id + ) + try: + # Confirm on the original message rather than replying: a reply + # to an edit event renders as "This event could not be displayed" + # in Element, and a message per keystroke-save is noise. + await self.client.react(evt.room_id, original_id, "📝") + except Exception: + self.log.debug("Could not react to edited entry", exc_info=True) diff --git a/journal/maubot.yaml b/journal/maubot.yaml index 15a8807..7084d99 100644 --- a/journal/maubot.yaml +++ b/journal/maubot.yaml @@ -1,6 +1,6 @@ maubot: 0.1.0 id: dev.aiolabs.journal -version: 0.2.1 +version: 0.3.0 license: AGPL-3.0-or-later modules: - journal