Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -97,14 +97,19 @@ services.matebot = {

## Chat commands

Back-to-back shots are fine: the second one is announced right away and its
questionnaire starts as soon as the first is logged (or skipped) — nothing is
dropped, and `/fix <id>` reopens any shot later.

Besides the post-shot questionnaire, the bot answers commands (any messenger):

```
/wake turn the machine on — pings you when it's at temperature
/sleep back to standby
/status mode, boiler temperature, water level
/last the last logged shot (with journal link if configured)
/fix redo the questionnaire for the last shot
/fix redo the questionnaire for the last shot (or a given one: /fix 62)
/skip drop the questionnaire in progress, move on to the next queued shot
/help list commands
```

Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ build-backend = "hatchling.build"

[project]
name = "matebot"
version = "0.3.2"
version = "0.4.0"
description = "The proactive companion for GaggiMate espresso machines: post-shot logging bot, .slog decoder, shot-journal site generator"
readme = "README.md"
license = "MIT"
Expand Down
2 changes: 1 addition & 1 deletion src/matebot/__init__.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
"""matebot — the proactive companion for GaggiMate espresso machines."""

__version__ = "0.3.2"
__version__ = "0.4.0"
41 changes: 32 additions & 9 deletions src/matebot/commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,8 @@
"/sleep — back to standby\n"
"/status — mode, temperature, connectivity\n"
"/last — the last logged shot\n"
"/fix — redo the questionnaire for the last shot\n"
"/fix [shot] — redo the questionnaire for the last shot (or e.g. /fix 62)\n"
"/skip — drop the questionnaire in progress, move on to the next queued shot\n"
"/newbag <grams> [name] — start tracking a bean bag (optional feature)\n"
"/bag — how much is left in the open bags\n"
"/tossbag [name] — close out a bag (emptied, binned, or gifted)\n"
Expand Down Expand Up @@ -196,29 +197,51 @@ async def _cmd_last(self) -> None:
await self.messenger.send(text)

async def _cmd_fix(self) -> None:
last = self.state.get("last_shot")
if not last:
shot = self.state.get("last_shot")
if self._args:
try:
sid = int(self._args[0].lstrip("#"))
except ValueError:
await self.messenger.send("Usage: /fix [shot id] — e.g. /fix 62")
return
if not shot or shot["shot_id"] != sid:
index = await self.client.fetch_index()
entry = next((e for e in index.entries if e.id == sid and not e.deleted), None)
if entry is None:
await self.messenger.send(f"No shot #{sid} on the machine.")
return
shot = {
"shot_id": entry.id,
"profile": entry.profile_name,
"duration_ms": entry.duration_ms,
"volume_g": entry.volume_g,
}
if not shot:
await self.messenger.send("No shot to fix yet.")
return
await self.messenger.send(f"✏️ Let's redo shot #{last['shot_id']}:")
await self.messenger.send(f"✏️ Let's redo shot #{shot['shot_id']}:")
photo = None
if getattr(self.config, "plots_enabled", False):
try:
from .plot import render_shot_png
from .slog import parse_slog

parsed = parse_slog(await self.client.fetch_slog(last["shot_id"]))
parsed = parse_slog(await self.client.fetch_slog(shot["shot_id"]))
photo = render_shot_png(
parsed, title=f"Shot #{last['shot_id']} — {parsed.profile_name}"
parsed, title=f"Shot #{shot['shot_id']} — {parsed.profile_name}"
)
except Exception as exc: # noqa: BLE001 - photo is a nice-to-have
log.info("fix plot skipped: %s", exc)
await self.convo.start_shot(
last["shot_id"], last.get("profile", ""),
last.get("duration_ms", 0), last.get("volume_g", 0.0),
photo=photo,
shot["shot_id"], shot.get("profile", ""),
shot.get("duration_ms", 0), shot.get("volume_g", 0.0),
photo=photo, now=True,
)

async def _cmd_skip(self) -> None:
if not await self.convo.skip():
await self.messenger.send("Nothing to skip — no questionnaire in progress.")

async def _cmd_newbag(self) -> None:
from . import bags

Expand Down
111 changes: 86 additions & 25 deletions src/matebot/conversation.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
"""The post-shot questionnaire — messenger-agnostic.

One questionnaire at a time (it's a home espresso machine, not a fleet).
A new shot supersedes a pending questionnaire: whatever was already answered
is saved (partial notes beat no notes), then the new one starts.
One questionnaire at a time (it's a home espresso machine, not a fleet), but
shots queue up: pull two back to back and the second one is announced right
away, then waits its turn — its questionnaire starts the moment the first is
logged (or /skip'ped). Nothing is dropped, and /fix <id> reopens any shot.

Flow: RATING → TASTE → BEAN → GRIND → DOSE_IN → DOSE_OUT → NOTES → save.
Answers land in the machine's own "Shot Notes" (via req:history:notes:save),
Expand Down Expand Up @@ -71,6 +72,13 @@ class PendingShot:
step: str = "r"
answers: dict | None = None

@property
def headline(self) -> str:
return (
f"{self.profile} · {_fmt_duration(self.duration_ms)}"
+ (f" · {self.volume_g:.1f} g in the cup" if self.volume_g else "")
)

def to_dict(self) -> dict:
return {
"shot_id": self.shot_id,
Expand Down Expand Up @@ -107,36 +115,65 @@ def __init__(
restored = state.get("pending")
if restored:
self.pending = PendingShot.from_dict(restored)
# shots waiting for their questionnaire, oldest first
self.queue: list[PendingShot] = [
PendingShot.from_dict(d) for d in state.get("queue") or []
]
self._msg_ref: str | None = None

# ------------------------------------------------------------- shots

async def start_shot(
self, shot_id: int, profile: str, duration_ms: int, volume_g: float,
photo: bytes | None = None,
photo: bytes | None = None, *, now: bool = False,
) -> None:
if self.pending is not None:
await self._finish(superseded_by=shot_id)
self.pending = PendingShot(shot_id, profile, duration_ms, volume_g, answers={})
"""A shot finished: start its questionnaire, or queue it behind the
one in progress. ``now`` (used by /fix) jumps the queue instead — the
questionnaire in progress is parked at the front, answers intact."""
shot = PendingShot(shot_id, profile, duration_ms, volume_g, answers={})
self.queue = [q for q in self.queue if q.shot_id != shot_id]
parked = None
if self.pending is not None and self.pending.shot_id != shot_id:
if not now:
self.queue.append(shot)
self._persist()
more = len(self.queue) - 1
await self._send(
f"☕ Shot #{shot_id} done!\n{shot.headline}\n\n"
f"Queued — I'll ask about it right after #{self.pending.shot_id}"
+ (f" (+{more} more)" if more else "")
+ ". /skip drops the current one.",
photo,
)
return
parked = self.pending
self.queue.insert(0, parked)
self.pending = shot
self._persist()
summary = (
f"☕ Shot #{shot_id} done!\n"
f"{profile} · {_fmt_duration(duration_ms)}"
+ (f" · {volume_g:.1f} g in the cup" if volume_g else "")
+ "\n\nLet's log it before you forget:"
)
if photo:
await self.messenger.send_photo(photo, summary)
else:
await self.messenger.send(summary)
text = f"☕ Shot #{shot_id} done!\n{shot.headline}\n\nLet's log it before you forget:"
if parked is not None:
text += f"\n(#{parked.shot_id} is parked — we'll get back to it right after.)"
await self._send(text, photo)
await self._prompt()

async def skip(self) -> bool:
"""/skip: drop the questionnaire in progress and move on to the next
queued shot. Whatever was already answered is still saved."""
if self.pending is None:
return False
await self._finish(skipped=True)
return True

async def resume_if_pending(self) -> None:
if self.pending is not None:
await self.messenger.send(
text = (
f"☕ Shot #{self.pending.shot_id} is still waiting for its log — "
"where were we?"
)
if self.queue:
n = len(self.queue)
text += f" ({n} more shot{'s' if n > 1 else ''} queued behind it.)"
await self.messenger.send(text)
await self._prompt()

# ------------------------------------------------------------- events
Expand Down Expand Up @@ -204,6 +241,12 @@ def _options_for(self, step: str) -> list[Option]:
options.append(mk(step, "skip", "skip"))
return options

async def _send(self, text: str, photo: bytes | None = None) -> None:
if photo:
await self.messenger.send_photo(photo, text)
else:
await self.messenger.send(text)

async def _prompt(self) -> None:
step = self.pending.step
text = PROMPTS[step]
Expand All @@ -225,7 +268,7 @@ async def _advance(self, value: str | None) -> None:

# ------------------------------------------------------------- finish

async def _finish(self, superseded_by: int | None = None) -> None:
async def _finish(self, skipped: bool = False) -> None:
pending, self.pending = self.pending, None
self._persist()
answers = dict(pending.answers or {})
Expand All @@ -238,10 +281,9 @@ async def _finish(self, superseded_by: int | None = None) -> None:
except ValueError:
pass

if superseded_by is not None and not answers:
await self.messenger.send(
f"⏭ Shot #{pending.shot_id} skipped (new shot #{superseded_by})."
)
if skipped and not answers:
await self.messenger.send(f"⏭ Shot #{pending.shot_id} skipped.")
await self._next()
return

ok = await self.save_notes(pending.shot_id, answers) if answers else True
Expand All @@ -252,7 +294,7 @@ async def _finish(self, superseded_by: int | None = None) -> None:
last.update({k: v for k, v in answers.items() if k in remembered})
self.state.set("last_notes", last)
stars = "★" * int(answers.get("rating", 0))
suffix = f" (superseded by #{superseded_by})" if superseded_by else ""
suffix = " (what you had so far)" if skipped else ""
ratio = f" · 1:{answers['ratio']}" if answers.get("ratio") else ""
from datetime import datetime

Expand All @@ -265,6 +307,25 @@ async def _finish(self, superseded_by: int | None = None) -> None:
f"⚠️ Couldn't reach the machine to save notes for shot #{pending.shot_id}. "
"They'll appear once it's back online — or re-enter them in the web UI."
)
await self._next()

async def _next(self) -> None:
"""Pop the next queued shot (if any) and start — or resume — its log."""
if not self.queue:
return
self.pending = self.queue.pop(0)
self._persist()
more = len(self.queue)
resumed = bool(self.pending.answers)
await self.messenger.send(
f"☕ Next up: shot #{self.pending.shot_id}\n{self.pending.headline}\n\n"
+ ("Picking up where we left off:" if resumed else "Let's log it:")
+ (f" ({more} more after this)" if more else "")
)
await self._prompt()

def _persist(self) -> None:
self.state.set("pending", self.pending.to_dict() if self.pending else None)
self.state.update(
pending=self.pending.to_dict() if self.pending else None,
queue=[q.to_dict() for q in self.queue],
)
53 changes: 50 additions & 3 deletions tests/test_commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,9 +35,14 @@ async def send_event(self, tp, **fields):
class FakeConvo:
def __init__(self):
self.started = []
self.pending = None

async def start_shot(self, *args, photo=None):
self.started.append(args)
async def start_shot(self, *args, photo=None, now=False):
self.started.append((args, now))

async def skip(self):
had, self.pending = self.pending, None
return had is not None


@pytest.fixture
Expand Down Expand Up @@ -107,7 +112,7 @@ async def test_last_and_fix(setup):
assert "Shot #60" in fm.sent[-1] and "#000060" in fm.sent[-1]

await router.handle("/fix")
assert convo.started == [(60, "Direct Lever v3", 16000, 35.8)]
assert convo.started == [((60, "Direct Lever v3", 16000, 35.8), True)]


@pytest.mark.asyncio
Expand Down Expand Up @@ -369,3 +374,45 @@ async def test_vsync_adjusts_latest_video_offset(setup, tmp_path):
assert "#80" in fm.sent[-1]
await router.handle("/vsync -0.25")
assert videomod.get_offset(tmp_path, 80) == -0.75


class _Entry:
def __init__(self, id, name="Lever", duration_ms=28000, volume_g=36.0, deleted=False):
self.id, self.profile_name, self.duration_ms = id, name, duration_ms
self.volume_g, self.deleted = volume_g, deleted


class _Index:
def __init__(self, *entries):
self.entries = list(entries)


@pytest.mark.asyncio
async def test_skip_command(setup):
router, client, state, convo, fm, cache = setup
assert await router.handle("/skip")
assert "Nothing to skip" in fm.sent[-1]
convo.pending = object()
assert await router.handle("/skip")
assert convo.pending is None and len(fm.sent) == 1 # convo does the talking


@pytest.mark.asyncio
async def test_fix_by_shot_id(setup):
router, client, state, convo, fm, cache = setup
state.set("last_shot", {"shot_id": 63, "profile": "p", "duration_ms": 30000, "volume_g": 0})

async def fetch_index():
return _Index(_Entry(61, deleted=True), _Entry(62))

client.fetch_index = fetch_index
assert await router.handle("/fix")
assert convo.started[-1] == ((63, "p", 30000, 0), True)
assert await router.handle("/fix 62")
assert convo.started[-1] == ((62, "Lever", 28000, 36.0), True)
assert await router.handle("/fix #63") # last shot: no index lookup needed
assert convo.started[-1][0][0] == 63
assert await router.handle("/fix 61")
assert "No shot #61" in fm.sent[-1] and len(convo.started) == 3
assert await router.handle("/fix abc")
assert "Usage" in fm.sent[-1]
Loading
Loading