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>
This commit is contained in:
Padreug 2026-05-24 09:32:22 +02:00
commit 057ed0ed45
2 changed files with 63 additions and 41 deletions

View file

@ -1,5 +1,5 @@
import re
from datetime import datetime, timezone from datetime import datetime, timezone
from typing import Optional
from maubot import MessageEvent, Plugin from maubot import MessageEvent, Plugin
from maubot.handlers import command from maubot.handlers import command
@ -25,6 +25,22 @@ 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)")
# 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: def _fmt(rows) -> str:
if not rows: if not rows:
return "No entries." return "No entries."
@ -40,35 +56,27 @@ class JournalBot(Plugin):
def get_db_upgrade_table(cls) -> UpgradeTable: def get_db_upgrade_table(cls) -> UpgradeTable:
return upgrade_table return upgrade_table
@command.new( @command.passive(regex=_JOURNAL_RE)
"journal", async def journal(self, evt: MessageEvent, match) -> None:
help="Farm journal — record what you did today", rest = (match[1] or "").strip()
require_subcommand=False,
arg_fallthrough=False, if not rest:
) await evt.reply(_USAGE)
@command.argument("text", pass_raw=True, required=False)
async def journal(self, evt: MessageEvent, text: str = "") -> None:
if not text:
await evt.reply(
"Usage:\n"
"- `!journal <what you did>` — record an entry\n"
"- `!journal show [@user]` — last 10 entries (optionally filtered by user)\n"
"- `!journal today` — all entries from today"
)
return return
await self.database.execute( # Subcommand detection on the first whitespace-delimited token of
"INSERT INTO entries (user, room, ts, text) VALUES ($1, $2, $3, $4)", # the first line — only catches `show`/`today` if they appear
evt.sender, evt.room_id, evt.timestamp, text, # alone on the first line (with optional arg). Anything else
) # (including pasted multi-line lists) is recorded as-is.
await evt.reply(f"📓 Logged for {evt.sender}.") first_line, _, _ = rest.partition("\n")
first_token, _, after = first_line.partition(" ")
@journal.subcommand("show", help="Show recent entries, optionally filtered by user") if first_token == "show":
@command.argument("user", required=False) user = after.strip() or None
async def show(self, evt: MessageEvent, user: Optional[str] = None) -> None:
if user: if user:
rows = await self.database.fetch( rows = await self.database.fetch(
"SELECT user, ts, text FROM entries WHERE user = $1 ORDER BY ts DESC LIMIT 10", "SELECT user, ts, text FROM entries"
" WHERE user = $1 ORDER BY ts DESC LIMIT 10",
user, user,
) )
else: else:
@ -76,13 +84,27 @@ class JournalBot(Plugin):
"SELECT user, ts, text FROM entries ORDER BY ts DESC LIMIT 10", "SELECT user, ts, text FROM entries ORDER BY ts DESC LIMIT 10",
) )
await evt.reply(_fmt(rows)) await evt.reply(_fmt(rows))
return
@journal.subcommand("today", help="All entries from today (UTC) across users") if first_token == "today":
async def today(self, evt: MessageEvent) -> None: midnight = datetime.now(timezone.utc).replace(
midnight = datetime.now(timezone.utc).replace(hour=0, minute=0, second=0, microsecond=0) hour=0, minute=0, second=0, microsecond=0
)
cutoff_ms = int(midnight.timestamp() * 1000) cutoff_ms = int(midnight.timestamp() * 1000)
rows = await self.database.fetch( rows = await self.database.fetch(
"SELECT user, ts, text FROM entries WHERE ts >= $1 ORDER BY ts ASC", "SELECT user, ts, text FROM entries"
" WHERE ts >= $1 ORDER BY ts ASC",
cutoff_ms, cutoff_ms,
) )
await evt.reply(_fmt(rows)) 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}.")

View file

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