feat(journal): edits update the entry in place instead of duplicating

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019wNB1sgkcBGijWjKTjtwG8
This commit is contained in:
Padreug 2026-09-14 17:01:33 +02:00
commit 35b967b507
3 changed files with 75 additions and 31 deletions

View file

@ -40,10 +40,12 @@ CREATE TABLE entries (
user TEXT NOT NULL, -- @sender:domain user TEXT NOT NULL, -- @sender:domain
room TEXT NOT NULL, -- !roomid:domain room TEXT NOT NULL, -- !roomid:domain
ts BIGINT NOT NULL, -- ms since epoch (from evt.timestamp) 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_user_ts ON entries (user, ts DESC);
CREATE INDEX entries_ts ON entries (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: Wipe data via the maubot UI's per-instance **Database** tab:
@ -58,26 +60,32 @@ rather keep IDs monotonic across resets.)
## Known quirks ## Known quirks
- **Editing a recorded entry does nothing.** Edits are deliberately - **Editing a `!journal` message updates its entry** (since v0.3.0).
ignored (since v0.2.1), so fixing a typo in a `!journal` message Fix a typo, add a line, and the stored entry changes in place — the
leaves the stored entry unchanged. Send a fresh `!journal` message bot confirms with a 📝 reaction on your message rather than posting
instead of editing. 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 This works because entries are keyed on the source event id, added in
the bot at all. That was wrong, and the bug it hid was noisy: mautrix the v2 migration. Entries recorded before v0.3.0 have `event_id IS
swaps an edit's content for `m.new_content` before the plugin sees it 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 `* ` (`mautrix/types/event/message.py:393-396`), stripping the `* `
fallback prefix. The handler therefore received something identical fallback prefix, so an edit is **indistinguishable from a new
to a brand-new command and recorded a **duplicate entry on every command**. Neither `@command.passive` nor `@command.new` filters
edit** — one journal message edited five times produced five rows. them. A plugin that doesn't call `evt.content.get_edit()` will
Neither `@command.passive` nor `@command.new` filters edits, so any silently record a duplicate on every edit — which is exactly what
maubot plugin matching on message bodies needs its own this plugin did before v0.3.0.
`if evt.content.get_edit(): return` guard.
- **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 <random text>` runs the show query with that text - **`!journal show <random text>` runs the show query with that text
as the user filter.** If it doesn't match any MXID, you get as the user filter.** If it doesn't match any MXID, you get
"No entries." Use a fully-qualified MXID like `@pat:ariege.io`. "No entries." Use a fully-qualified MXID like `@pat:ariege.io`.

View file

@ -25,6 +25,12 @@ async def upgrade_v1(conn: Connection) -> None:
await conn.execute("CREATE INDEX entries_ts ON entries (ts DESC)") 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) # Match `!journal` followed by any whitespace (space, tab, OR newline)
# and capture everything after. Maubot's @command.new parser only treats # and capture everything after. Maubot's @command.new parser only treats
# *space* as the command/args delimiter, so `!journal\n<content>` gets # *space* as the command/args delimiter, so `!journal\n<content>` gets
@ -58,19 +64,20 @@ class JournalBot(Plugin):
@command.passive(regex=_JOURNAL_RE) @command.passive(regex=_JOURNAL_RE)
async def journal(self, evt: MessageEvent, match) -> None: 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() 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: if not rest:
await evt.reply(_USAGE) await evt.reply(_USAGE)
return return
@ -112,10 +119,39 @@ class JournalBot(Plugin):
# Default: record the full rest (multi-line preserved) # Default: record the full rest (multi-line preserved)
await self.database.execute( 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.sender,
evt.room_id, evt.room_id,
evt.timestamp, evt.timestamp,
rest, rest,
evt.event_id,
) )
await evt.reply(f"📓 Logged for {evt.sender}.") 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)

View file

@ -1,6 +1,6 @@
maubot: 0.1.0 maubot: 0.1.0
id: dev.aiolabs.journal id: dev.aiolabs.journal
version: 0.2.1 version: 0.3.0
license: AGPL-3.0-or-later license: AGPL-3.0-or-later
modules: modules:
- journal - journal