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:
parent
3dc7eab2ab
commit
35b967b507
3 changed files with 75 additions and 31 deletions
|
|
@ -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`.
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue