maubot-plugins/journal
Padreug 35b967b507 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
2026-09-14 17:01:33 +02:00
..
journal.py feat(journal): edits update the entry in place instead of duplicating 2026-09-14 17:01:33 +02:00
maubot.yaml feat(journal): edits update the entry in place instead of duplicating 2026-09-14 17:01:33 +02:00
README.md feat(journal): edits update the entry in place instead of duplicating 2026-09-14 17:01:33 +02:00

journal

Daily-journal Matrix bot. Each room member can record what they did, and anyone in the room can query the log.

Commands

!journal <what you did>          record an entry (multi-line OK)
!journal show [@user:domain]     last 10 entries, optionally filtered by user
!journal today                   all entries from today (UTC)

Multi-line works either inline or after a newline:

!journal Did three things today:
- planted garlic
- mucked out the goat pen
- finished the irrigation patch
!journal
- planted garlic
- mucked out the goat pen

Both record the full body verbatim.

Storage

One SQLite database per maubot instance, at /var/lib/maubot/plugin-dbs/journal.db on the host. Schema (managed by mautrix.util.async_db.UpgradeTable):

CREATE TABLE entries (
    id   INTEGER PRIMARY KEY AUTOINCREMENT,
    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
    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:

DELETE FROM entries;
DELETE FROM sqlite_sequence WHERE name = 'entries';

(The second line resets the auto-increment counter; skip it if you'd rather keep IDs monotonic across resets.)

Known quirks

  • 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 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, 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.

  • !journal show <random text> 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.

  • Subcommand detection only looks at the first line. Anything starting with show or today on the first line dispatches to the query handlers; anything else (including prose that happens to contain "show" mid-text) records as an entry.

Architecture note

This plugin uses @command.passive with a regex matcher rather than @command.new. The reason — and why other plugins should consider the same pattern for prose-input commands — is documented in ~/dev/CLAUDE.md under "Multi-line freeform parent commands". Short version: @command.new silently drops !journal\n<content> because maubot's parser only treats space as the command/args delimiter, leading to invisible data loss when users naturally hit Enter after the command.