maubot-plugins/journal/journal.py
Padreug 057ed0ed45 feat(journal): passive matcher for robust multi-line entries
`@command.new` silently drops `!journal\n<content>` because
maubot's parser only treats a *space* as the command/args
delimiter — when a newline immediately follows the command
name, the parser fails to recognise the command at all (no
handler invoked, no error). Real users WILL paste:

    !journal
    - thing one
    - thing two

and lose the entry without any feedback.

Switching to `@command.passive` with a regex that admits
[ \t\r\n] as the delimiter catches every form. Subcommand
dispatch (show/today) moves into the handler body — small
loss of decorator ergonomics, big gain in robustness for the
dominant use case (freeform multi-line entries).

Bumped to 0.2.0 since the structural change warrants a minor
bump (not a fix-level patch).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 09:32:22 +02:00

110 lines
3.8 KiB
Python

import re
from datetime import datetime, timezone
from maubot import MessageEvent, Plugin
from maubot.handlers import command
from mautrix.util.async_db import Connection, UpgradeTable
upgrade_table = UpgradeTable()
@upgrade_table.register(description="Initial schema")
async def upgrade_v1(conn: Connection) -> None:
await conn.execute(
"""
CREATE TABLE entries (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user TEXT NOT NULL,
room TEXT NOT NULL,
ts BIGINT NOT NULL,
text TEXT NOT NULL
)
"""
)
await conn.execute("CREATE INDEX entries_user_ts ON entries (user, ts DESC)")
await conn.execute("CREATE INDEX entries_ts ON entries (ts DESC)")
# 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<content>` gets
# parsed as a command name of "journal\n<content>" and matches nothing,
# silently dropping multi-line entries. A passive regex matcher with
# DOTALL bypasses the parser quirk and catches every form.
_JOURNAL_RE = re.compile(r"^!journal(?:[ \t\r\n]+(.*))?$", re.DOTALL)
_USAGE = (
"Usage:\n"
"- `!journal <what you did>` — record an entry (multi-line OK)\n"
"- `!journal show [@user]` — last 10 entries, optionally filtered\n"
"- `!journal today` — all entries from today (UTC)"
)
def _fmt(rows) -> str:
if not rows:
return "No entries."
out = []
for r in rows:
when = datetime.fromtimestamp(r["ts"] / 1000, tz=timezone.utc).strftime("%Y-%m-%d %H:%M")
out.append(f"- **{r['user']}** _({when} UTC)_: {r['text']}")
return "\n".join(out)
class JournalBot(Plugin):
@classmethod
def get_db_upgrade_table(cls) -> UpgradeTable:
return upgrade_table
@command.passive(regex=_JOURNAL_RE)
async def journal(self, evt: MessageEvent, match) -> None:
rest = (match[1] or "").strip()
if not rest:
await evt.reply(_USAGE)
return
# Subcommand detection on the first whitespace-delimited token of
# the first line — only catches `show`/`today` if they appear
# alone on the first line (with optional arg). Anything else
# (including pasted multi-line lists) is recorded as-is.
first_line, _, _ = rest.partition("\n")
first_token, _, after = first_line.partition(" ")
if first_token == "show":
user = after.strip() or None
if user:
rows = await self.database.fetch(
"SELECT user, ts, text FROM entries"
" WHERE user = $1 ORDER BY ts DESC LIMIT 10",
user,
)
else:
rows = await self.database.fetch(
"SELECT user, ts, text FROM entries ORDER BY ts DESC LIMIT 10",
)
await evt.reply(_fmt(rows))
return
if first_token == "today":
midnight = datetime.now(timezone.utc).replace(
hour=0, minute=0, second=0, microsecond=0
)
cutoff_ms = int(midnight.timestamp() * 1000)
rows = await self.database.fetch(
"SELECT user, ts, text FROM entries"
" WHERE ts >= $1 ORDER BY ts ASC",
cutoff_ms,
)
await evt.reply(_fmt(rows))
return
# Default: record the full rest (multi-line preserved)
await self.database.execute(
"INSERT INTO entries (user, room, ts, text) VALUES ($1, $2, $3, $4)",
evt.sender,
evt.room_id,
evt.timestamp,
rest,
)
await evt.reply(f"📓 Logged for {evt.sender}.")