matebot 0.2.0__tar.gz → 0.2.2__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {matebot-0.2.0 → matebot-0.2.2}/AGENTS.md +2 -2
- {matebot-0.2.0 → matebot-0.2.2}/PKG-INFO +5 -5
- {matebot-0.2.0 → matebot-0.2.2}/README.md +4 -4
- {matebot-0.2.0 → matebot-0.2.2}/flake.nix +1 -1
- {matebot-0.2.0 → matebot-0.2.2}/pyproject.toml +1 -1
- {matebot-0.2.0 → matebot-0.2.2}/src/matebot/__init__.py +1 -1
- matebot-0.2.2/src/matebot/bags.py +134 -0
- {matebot-0.2.0 → matebot-0.2.2}/src/matebot/commands.py +7 -1
- {matebot-0.2.0 → matebot-0.2.2}/src/matebot/conversation.py +7 -0
- {matebot-0.2.0 → matebot-0.2.2}/src/matebot/messengers/telegram.py +1 -0
- {matebot-0.2.0 → matebot-0.2.2}/tests/test_bags.py +34 -0
- {matebot-0.2.0 → matebot-0.2.2}/tests/test_conversation.py +23 -0
- matebot-0.2.0/src/matebot/bags.py +0 -77
- {matebot-0.2.0 → matebot-0.2.2}/.github/workflows/ci.yml +0 -0
- {matebot-0.2.0 → matebot-0.2.2}/.github/workflows/docker.yml +0 -0
- {matebot-0.2.0 → matebot-0.2.2}/.github/workflows/release.yml +0 -0
- {matebot-0.2.0 → matebot-0.2.2}/.gitignore +0 -0
- {matebot-0.2.0 → matebot-0.2.2}/CLAUDE.md +0 -0
- {matebot-0.2.0 → matebot-0.2.2}/Dockerfile +0 -0
- {matebot-0.2.0 → matebot-0.2.2}/LICENSE +0 -0
- {matebot-0.2.0 → matebot-0.2.2}/docker-compose.example.yml +0 -0
- {matebot-0.2.0 → matebot-0.2.2}/docs/screenshots/journal-detail.png +0 -0
- {matebot-0.2.0 → matebot-0.2.2}/docs/screenshots/journal-list.png +0 -0
- {matebot-0.2.0 → matebot-0.2.2}/docs/screenshots/telegram-chat.png +0 -0
- {matebot-0.2.0 → matebot-0.2.2}/flake.lock +0 -0
- {matebot-0.2.0 → matebot-0.2.2}/src/matebot/cli.py +0 -0
- {matebot-0.2.0 → matebot-0.2.2}/src/matebot/config.py +0 -0
- {matebot-0.2.0 → matebot-0.2.2}/src/matebot/digest.py +0 -0
- {matebot-0.2.0 → matebot-0.2.2}/src/matebot/hints.py +0 -0
- {matebot-0.2.0 → matebot-0.2.2}/src/matebot/machine.py +0 -0
- {matebot-0.2.0 → matebot-0.2.2}/src/matebot/messengers/__init__.py +0 -0
- {matebot-0.2.0 → matebot-0.2.2}/src/matebot/messengers/base.py +0 -0
- {matebot-0.2.0 → matebot-0.2.2}/src/matebot/messengers/discord.py +0 -0
- {matebot-0.2.0 → matebot-0.2.2}/src/matebot/plot.py +0 -0
- {matebot-0.2.0 → matebot-0.2.2}/src/matebot/sitegen.py +0 -0
- {matebot-0.2.0 → matebot-0.2.2}/src/matebot/slog.py +0 -0
- {matebot-0.2.0 → matebot-0.2.2}/src/matebot/state.py +0 -0
- {matebot-0.2.0 → matebot-0.2.2}/src/matebot/sync.py +0 -0
- {matebot-0.2.0 → matebot-0.2.2}/src/matebot/watcher.py +0 -0
- {matebot-0.2.0 → matebot-0.2.2}/src/matebot/web/app.js +0 -0
- {matebot-0.2.0 → matebot-0.2.2}/src/matebot/web/index.html +0 -0
- {matebot-0.2.0 → matebot-0.2.2}/src/matebot/web/style.css +0 -0
- {matebot-0.2.0 → matebot-0.2.2}/src/matebot/web/vendor/chart.umd.js +0 -0
- {matebot-0.2.0 → matebot-0.2.2}/src/matebot/web/vendor/chartjs-plugin-annotation.min.js +0 -0
- {matebot-0.2.0 → matebot-0.2.2}/tests/fixtures/000004.slog +0 -0
- {matebot-0.2.0 → matebot-0.2.2}/tests/fixtures/status_frames.jsonl +0 -0
- {matebot-0.2.0 → matebot-0.2.2}/tests/test_cleaning.py +0 -0
- {matebot-0.2.0 → matebot-0.2.2}/tests/test_commands.py +0 -0
- {matebot-0.2.0 → matebot-0.2.2}/tests/test_digest.py +0 -0
- {matebot-0.2.0 → matebot-0.2.2}/tests/test_hints.py +0 -0
- {matebot-0.2.0 → matebot-0.2.2}/tests/test_plot.py +0 -0
- {matebot-0.2.0 → matebot-0.2.2}/tests/test_sitegen.py +0 -0
- {matebot-0.2.0 → matebot-0.2.2}/tests/test_slog.py +0 -0
- {matebot-0.2.0 → matebot-0.2.2}/tests/test_sync_retry.py +0 -0
- {matebot-0.2.0 → matebot-0.2.2}/tests/test_watcher.py +0 -0
|
@@ -23,7 +23,7 @@ The decision tree:
|
|
|
23
23
|
- Most users → **Docker**: copy `docker-compose.example.yml`, fill in env
|
|
24
24
|
vars, `docker compose up -d`. Image: `ghcr.io/alexnly/matebot:latest`
|
|
25
25
|
(multi-arch, Raspberry Pi works).
|
|
26
|
-
- Python users → `
|
|
26
|
+
- Python users → `pipx install "matebot[telegram]"` (or plain pip),
|
|
27
27
|
then `matebot run` (systemd unit or `screen` for persistence).
|
|
28
28
|
- NixOS users → flake input + `services.matebot` module (see README).
|
|
29
29
|
3. **Telegram bot** (the fiddly part for novices — walk them through it):
|
|
@@ -42,7 +42,7 @@ The decision tree:
|
|
|
42
42
|
arrive within seconds of the shot ending.
|
|
43
43
|
6. **Optional extras**, in ascending effort: journal repo + GitHub Pages
|
|
44
44
|
(README "Publishing your journal"), dial-in hints (on by default), bean
|
|
45
|
-
bag tracking (`/newbag`), smart plug hooks for cold-start `/wake`
|
|
45
|
+
bag tracking (`/newbag`, multiple bags, `/tossbag`), smart plug hooks for cold-start `/wake`
|
|
46
46
|
(README "Smart plug cold start").
|
|
47
47
|
|
|
48
48
|
Common failure modes: token/chat id swapped or quoted wrong; bot and machine
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: matebot
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.2
|
|
4
4
|
Summary: The proactive companion for GaggiMate espresso machines: post-shot logging bot, .slog decoder, shot-journal site generator
|
|
5
5
|
Project-URL: Homepage, https://github.com/AlexNly/matebot
|
|
6
6
|
Project-URL: Issues, https://github.com/AlexNly/matebot/issues
|
|
@@ -48,7 +48,7 @@ straight back into GaggiMate's own Shot Notes, exactly as if you'd typed them
|
|
|
48
48
|
into the web UI.
|
|
49
49
|
|
|
50
50
|
<p align="center">
|
|
51
|
-
<img src="docs/screenshots/telegram-chat.png" width="420" alt="matebot asking for a shot rating on Telegram right after the shot finished">
|
|
51
|
+
<img src="https://raw.githubusercontent.com/AlexNly/MATEbot/main/docs/screenshots/telegram-chat.png" width="420" alt="matebot asking for a shot rating on Telegram right after the shot finished">
|
|
52
52
|
</p>
|
|
53
53
|
|
|
54
54
|
## What it does
|
|
@@ -77,8 +77,8 @@ the familiar combined pressure/flow/temperature chart, phase markers, ratings
|
|
|
77
77
|
and notes.
|
|
78
78
|
|
|
79
79
|
<p align="center">
|
|
80
|
-
<img src="docs/screenshots/journal-list.png" width="49%" alt="Shot journal list with ratings, ratios and peak pressure">
|
|
81
|
-
<img src="docs/screenshots/journal-detail.png" width="49%" alt="Per-shot detail with combined pressure/flow/temperature chart">
|
|
80
|
+
<img src="https://raw.githubusercontent.com/AlexNly/MATEbot/main/docs/screenshots/journal-list.png" width="49%" alt="Shot journal list with ratings, ratios and peak pressure">
|
|
81
|
+
<img src="https://raw.githubusercontent.com/AlexNly/MATEbot/main/docs/screenshots/journal-detail.png" width="49%" alt="Per-shot detail with combined pressure/flow/temperature chart">
|
|
82
82
|
</p>
|
|
83
83
|
|
|
84
84
|
## Install
|
|
@@ -101,7 +101,7 @@ A Pi Zero 2 W and a USB charger is the whole data center.
|
|
|
101
101
|
### pip
|
|
102
102
|
|
|
103
103
|
```bash
|
|
104
|
-
|
|
104
|
+
pipx install "matebot[telegram]" # or: pip install "matebot[telegram]"
|
|
105
105
|
matebot run
|
|
106
106
|
```
|
|
107
107
|
|
|
@@ -14,7 +14,7 @@ straight back into GaggiMate's own Shot Notes, exactly as if you'd typed them
|
|
|
14
14
|
into the web UI.
|
|
15
15
|
|
|
16
16
|
<p align="center">
|
|
17
|
-
<img src="docs/screenshots/telegram-chat.png" width="420" alt="matebot asking for a shot rating on Telegram right after the shot finished">
|
|
17
|
+
<img src="https://raw.githubusercontent.com/AlexNly/MATEbot/main/docs/screenshots/telegram-chat.png" width="420" alt="matebot asking for a shot rating on Telegram right after the shot finished">
|
|
18
18
|
</p>
|
|
19
19
|
|
|
20
20
|
## What it does
|
|
@@ -43,8 +43,8 @@ the familiar combined pressure/flow/temperature chart, phase markers, ratings
|
|
|
43
43
|
and notes.
|
|
44
44
|
|
|
45
45
|
<p align="center">
|
|
46
|
-
<img src="docs/screenshots/journal-list.png" width="49%" alt="Shot journal list with ratings, ratios and peak pressure">
|
|
47
|
-
<img src="docs/screenshots/journal-detail.png" width="49%" alt="Per-shot detail with combined pressure/flow/temperature chart">
|
|
46
|
+
<img src="https://raw.githubusercontent.com/AlexNly/MATEbot/main/docs/screenshots/journal-list.png" width="49%" alt="Shot journal list with ratings, ratios and peak pressure">
|
|
47
|
+
<img src="https://raw.githubusercontent.com/AlexNly/MATEbot/main/docs/screenshots/journal-detail.png" width="49%" alt="Per-shot detail with combined pressure/flow/temperature chart">
|
|
48
48
|
</p>
|
|
49
49
|
|
|
50
50
|
## Install
|
|
@@ -67,7 +67,7 @@ A Pi Zero 2 W and a USB charger is the whole data center.
|
|
|
67
67
|
### pip
|
|
68
68
|
|
|
69
69
|
```bash
|
|
70
|
-
|
|
70
|
+
pipx install "matebot[telegram]" # or: pip install "matebot[telegram]"
|
|
71
71
|
matebot run
|
|
72
72
|
```
|
|
73
73
|
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "matebot"
|
|
7
|
-
version = "0.2.
|
|
7
|
+
version = "0.2.2"
|
|
8
8
|
description = "The proactive companion for GaggiMate espresso machines: post-shot logging bot, .slog decoder, shot-journal site generator"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = "MIT"
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
"""Bean bag tracking — strictly opt-in, multiple open bags supported.
|
|
2
|
+
|
|
3
|
+
Nothing happens until the user registers a bag with ``/newbag``. Open bags
|
|
4
|
+
appear as tap options in the questionnaire's bean question; a logged dose is
|
|
5
|
+
subtracted from the bag whose name matches the answered bean. ``/bag`` shows
|
|
6
|
+
all open bags, ``/tossbag`` closes one (emptied, binned, or gifted).
|
|
7
|
+
|
|
8
|
+
State shape: ``bags = [{"name", "total_g", "used_g", "shots", "rating_sum",
|
|
9
|
+
"warned"}, ...]`` (the legacy single-``bag`` key is migrated on first access).
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
DEFAULT_DOSE_G = 18.0
|
|
15
|
+
WARN_DOSES = 3
|
|
16
|
+
MAX_NAME_LEN = 40 # keeps bean names inside messenger option-id limits
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _bags(state) -> list[dict]:
|
|
20
|
+
bags = state.get("bags")
|
|
21
|
+
if bags is None:
|
|
22
|
+
legacy = state.get("bag")
|
|
23
|
+
bags = [legacy] if legacy else []
|
|
24
|
+
state.set("bags", bags)
|
|
25
|
+
state.set("bag", None)
|
|
26
|
+
return bags
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def open_bag_names(state) -> list[str]:
|
|
30
|
+
return [b["name"] for b in _bags(state)]
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def open_bag(state, total_g: float, name: str) -> str:
|
|
34
|
+
name = name.strip()[:MAX_NAME_LEN]
|
|
35
|
+
bags = [b for b in _bags(state) if b["name"] != name] # same name = new bag
|
|
36
|
+
bags.append({
|
|
37
|
+
"name": name, "total_g": total_g, "used_g": 0.0,
|
|
38
|
+
"shots": 0, "rating_sum": 0, "warned": False,
|
|
39
|
+
})
|
|
40
|
+
state.set("bags", bags)
|
|
41
|
+
return f"🫘 New bag registered: {name}, {total_g:.0f} g. I'll keep count."
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def toss_bag(state, name: str | None = None) -> str:
|
|
45
|
+
bags = _bags(state)
|
|
46
|
+
if not bags:
|
|
47
|
+
return "No open bags to toss."
|
|
48
|
+
if name:
|
|
49
|
+
matches = [b for b in bags if b["name"].lower() == name.strip().lower()]
|
|
50
|
+
if not matches:
|
|
51
|
+
return f"No open bag called “{name}”. Open bags: {', '.join(open_bag_names(state))}"
|
|
52
|
+
bag = matches[0]
|
|
53
|
+
elif len(bags) == 1:
|
|
54
|
+
bag = bags[0]
|
|
55
|
+
else:
|
|
56
|
+
return "Which one? /tossbag <name> — open bags: " + ", ".join(open_bag_names(state))
|
|
57
|
+
bags.remove(bag)
|
|
58
|
+
state.set("bags", bags)
|
|
59
|
+
left = max(0.0, bag["total_g"] - bag["used_g"])
|
|
60
|
+
line = f"🗑 Tossed “{bag['name']}” after {bag['shots']} shots"
|
|
61
|
+
if left > 20:
|
|
62
|
+
line += f" (~{left:.0f} g unused — a friend's gain, I hope)"
|
|
63
|
+
return line + "."
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def bag_status(state) -> str:
|
|
67
|
+
bags = _bags(state)
|
|
68
|
+
if not bags:
|
|
69
|
+
return "No bag registered. Start one with: /newbag <grams> [name]"
|
|
70
|
+
dose = _typical_dose(state)
|
|
71
|
+
lines = []
|
|
72
|
+
for bag in bags:
|
|
73
|
+
remaining = max(0.0, bag["total_g"] - bag["used_g"])
|
|
74
|
+
doses_left = int(remaining // dose) if dose else 0
|
|
75
|
+
line = (
|
|
76
|
+
f"🫘 {bag['name']}: {remaining:.0f} g of {bag['total_g']:.0f} g left"
|
|
77
|
+
f" (≈{doses_left} doses) · {bag['shots']} shots"
|
|
78
|
+
)
|
|
79
|
+
if bag["shots"] and bag["rating_sum"]:
|
|
80
|
+
line += f" · avg {'★' * round(bag['rating_sum'] / bag['shots'])}"
|
|
81
|
+
lines.append(line)
|
|
82
|
+
return "\n".join(lines)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def track_shot(state, notes: dict) -> str | None:
|
|
86
|
+
"""Subtract the logged dose from the matching bag; warn when running low.
|
|
87
|
+
|
|
88
|
+
The dose goes to the bag whose name equals the answered bean. With exactly
|
|
89
|
+
one open bag and no bean given, that bag gets it (the pre-multi-bag
|
|
90
|
+
behavior). Ambiguous cases are left untracked rather than guessed.
|
|
91
|
+
"""
|
|
92
|
+
bags = _bags(state)
|
|
93
|
+
if not bags:
|
|
94
|
+
return None
|
|
95
|
+
try:
|
|
96
|
+
dose = float(notes.get("doseIn", ""))
|
|
97
|
+
except (TypeError, ValueError):
|
|
98
|
+
dose = 0.0
|
|
99
|
+
if dose <= 0:
|
|
100
|
+
return None
|
|
101
|
+
|
|
102
|
+
bean = (notes.get("beanType") or "").strip().lower()
|
|
103
|
+
matches = [b for b in bags if b["name"].lower() == bean]
|
|
104
|
+
if not matches and not bean and len(bags) == 1:
|
|
105
|
+
matches = bags
|
|
106
|
+
if not matches:
|
|
107
|
+
return None
|
|
108
|
+
bag = matches[0]
|
|
109
|
+
|
|
110
|
+
bag["used_g"] += dose
|
|
111
|
+
bag["shots"] += 1
|
|
112
|
+
rating = notes.get("rating")
|
|
113
|
+
if isinstance(rating, int):
|
|
114
|
+
bag["rating_sum"] += rating
|
|
115
|
+
remaining = bag["total_g"] - bag["used_g"]
|
|
116
|
+
|
|
117
|
+
message = None
|
|
118
|
+
if remaining <= 0:
|
|
119
|
+
message = f"🫘 That was the last of '{bag['name']}' by my count — bag empty."
|
|
120
|
+
elif remaining < WARN_DOSES * dose and not bag["warned"]:
|
|
121
|
+
bag["warned"] = True
|
|
122
|
+
message = (
|
|
123
|
+
f"🫘 Heads-up: '{bag['name']}' is down to ~{remaining:.0f} g "
|
|
124
|
+
f"(≈{int(remaining // dose)} doses). Time to restock?"
|
|
125
|
+
)
|
|
126
|
+
state.set("bags", bags)
|
|
127
|
+
return message
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _typical_dose(state) -> float:
|
|
131
|
+
try:
|
|
132
|
+
return float(state.get("last_notes", {}).get("doseIn", DEFAULT_DOSE_G))
|
|
133
|
+
except (TypeError, ValueError):
|
|
134
|
+
return DEFAULT_DOSE_G
|
|
@@ -26,7 +26,8 @@ HELP = (
|
|
|
26
26
|
"/last — the last logged shot\n"
|
|
27
27
|
"/fix — redo the questionnaire for the last shot\n"
|
|
28
28
|
"/newbag <grams> [name] — start tracking a bean bag (optional feature)\n"
|
|
29
|
-
"/bag — how much is left in the
|
|
29
|
+
"/bag — how much is left in the open bags\n"
|
|
30
|
+
"/tossbag [name] — close out a bag (emptied, binned, or gifted)\n"
|
|
30
31
|
"/digest — your last 7 days in espresso\n"
|
|
31
32
|
"/help — this list"
|
|
32
33
|
)
|
|
@@ -199,6 +200,11 @@ class CommandRouter:
|
|
|
199
200
|
|
|
200
201
|
await self.messenger.send(bags.bag_status(self.state))
|
|
201
202
|
|
|
203
|
+
async def _cmd_tossbag(self) -> None:
|
|
204
|
+
from . import bags
|
|
205
|
+
|
|
206
|
+
await self.messenger.send(bags.toss_bag(self.state, " ".join(self._args) or None))
|
|
207
|
+
|
|
202
208
|
async def _cmd_digest(self) -> None:
|
|
203
209
|
text = await build_digest(self.client, self.config)
|
|
204
210
|
await self.messenger.send(text or "No shots in the last 7 days. The machine misses you.")
|
|
@@ -188,6 +188,13 @@ class Conversation:
|
|
|
188
188
|
]
|
|
189
189
|
options = []
|
|
190
190
|
default = self._defaults().get(NOTE_KEYS[step])
|
|
191
|
+
if step == "bean":
|
|
192
|
+
from . import bags
|
|
193
|
+
|
|
194
|
+
for name in bags.open_bag_names(self.state)[:3]:
|
|
195
|
+
options.append(mk("bean", name, f"🫘 {name}"[:60]))
|
|
196
|
+
if name == default:
|
|
197
|
+
default = None # avoid a duplicate "same as last" button
|
|
191
198
|
if step == "dout" and self.pending.volume_g:
|
|
192
199
|
grams = f"{self.pending.volume_g:.1f}"
|
|
193
200
|
options.append(mk("dout", grams, f"Use {grams} g"))
|
|
@@ -53,6 +53,7 @@ class TelegramMessenger(Messenger):
|
|
|
53
53
|
BotCommand("fix", "redo the last shot's log"),
|
|
54
54
|
BotCommand("bag", "how much is left in the bean bag"),
|
|
55
55
|
BotCommand("newbag", "start tracking a bean bag"),
|
|
56
|
+
BotCommand("tossbag", "close out a bag"),
|
|
56
57
|
BotCommand("help", "list commands"),
|
|
57
58
|
])
|
|
58
59
|
await self.app.updater.start_polling(drop_pending_updates=True)
|
|
@@ -45,3 +45,37 @@ def test_skipped_dose_not_counted(tmp_path):
|
|
|
45
45
|
assert track_shot(state, {}) is None
|
|
46
46
|
assert track_shot(state, {"doseIn": "garbage"}) is None
|
|
47
47
|
assert "250 g of 250 g" in bag_status(state)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def test_multi_bag_attribution(tmp_path):
|
|
51
|
+
state = make_state(tmp_path)
|
|
52
|
+
open_bag(state, 250, "Mondo Classico")
|
|
53
|
+
open_bag(state, 500, "Supermarket Blend")
|
|
54
|
+
track_shot(state, {"doseIn": "18", "beanType": "Mondo Classico", "rating": 5})
|
|
55
|
+
track_shot(state, {"doseIn": "20", "beanType": "supermarket blend"}) # case-insensitive
|
|
56
|
+
track_shot(state, {"doseIn": "18"}) # ambiguous with two bags: untracked
|
|
57
|
+
status = bag_status(state)
|
|
58
|
+
assert "232 g of 250 g" in status
|
|
59
|
+
assert "480 g of 500 g" in status
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def test_toss_bag(tmp_path):
|
|
63
|
+
from matebot.bags import open_bag_names, toss_bag
|
|
64
|
+
|
|
65
|
+
state = make_state(tmp_path)
|
|
66
|
+
assert "No open bags" in toss_bag(state)
|
|
67
|
+
open_bag(state, 250, "Mondo Classico")
|
|
68
|
+
open_bag(state, 250, "Gift Beans")
|
|
69
|
+
assert "Which one?" in toss_bag(state) # ambiguous
|
|
70
|
+
assert "Gift Beans" in toss_bag(state, "gift beans")
|
|
71
|
+
assert open_bag_names(state) == ["Mondo Classico"]
|
|
72
|
+
assert "Tossed" in toss_bag(state) # single bag: no name needed
|
|
73
|
+
assert open_bag_names(state) == []
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def test_legacy_single_bag_migrates(tmp_path):
|
|
77
|
+
state = make_state(tmp_path)
|
|
78
|
+
state.set("bag", {"name": "Old Beans", "total_g": 250, "used_g": 50,
|
|
79
|
+
"shots": 3, "rating_sum": 12, "warned": False})
|
|
80
|
+
assert "Old Beans: 200 g" in bag_status(state)
|
|
81
|
+
assert state.get("bag") is None
|
|
@@ -168,3 +168,26 @@ def test_signoff_matches_time_of_day():
|
|
|
168
168
|
assert "evening" in _signoff(19)
|
|
169
169
|
assert "dreams" in _signoff(23)
|
|
170
170
|
assert "dreams" in _signoff(2)
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
@pytest.mark.asyncio
|
|
174
|
+
async def test_bean_step_offers_open_bags(tmp_path):
|
|
175
|
+
from matebot.bags import open_bag
|
|
176
|
+
|
|
177
|
+
fm = FakeMessenger()
|
|
178
|
+
state = State(tmp_path / "state.json")
|
|
179
|
+
open_bag(state, 250, "Mondo Classico")
|
|
180
|
+
open_bag(state, 250, "Ethiopia Natural")
|
|
181
|
+
state.set("last_notes", {"beanType": "Mondo Classico"})
|
|
182
|
+
c = Conversation(fm, state, SaveRecorder())
|
|
183
|
+
await c.start_shot(70, "p", 30000, 0)
|
|
184
|
+
await answer(c, "g|70|r|4")
|
|
185
|
+
await answer(c, "g|70|bt|skip")
|
|
186
|
+
labels = [o.label for o in fm.last_options]
|
|
187
|
+
assert "🫘 Mondo Classico" in labels
|
|
188
|
+
assert "🫘 Ethiopia Natural" in labels
|
|
189
|
+
# no duplicate "Same as last: Mondo Classico" next to the bag button
|
|
190
|
+
assert not any("Same as last" in label for label in labels)
|
|
191
|
+
bag_option = next(o for o in fm.last_options if o.label == "🫘 Ethiopia Natural")
|
|
192
|
+
await c.handle_event(OptionSelected(bag_option.id))
|
|
193
|
+
assert c.pending.answers["beanType"] == "Ethiopia Natural"
|
|
@@ -1,77 +0,0 @@
|
|
|
1
|
-
"""Bean bag tracking — strictly opt-in.
|
|
2
|
-
|
|
3
|
-
Nothing happens until the user registers a bag with ``/newbag``; from then on
|
|
4
|
-
every logged dose-in is subtracted, and the bot warns once when roughly three
|
|
5
|
-
doses are left. ``/bag`` shows the current state at any time.
|
|
6
|
-
|
|
7
|
-
State shape (in the bot's state file):
|
|
8
|
-
bag = {"name": str, "total_g": float, "used_g": float,
|
|
9
|
-
"shots": int, "rating_sum": int, "warned": bool}
|
|
10
|
-
"""
|
|
11
|
-
|
|
12
|
-
from __future__ import annotations
|
|
13
|
-
|
|
14
|
-
DEFAULT_DOSE_G = 18.0
|
|
15
|
-
WARN_DOSES = 3
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
def open_bag(state, total_g: float, name: str) -> str:
|
|
19
|
-
state.set("bag", {
|
|
20
|
-
"name": name, "total_g": total_g, "used_g": 0.0,
|
|
21
|
-
"shots": 0, "rating_sum": 0, "warned": False,
|
|
22
|
-
})
|
|
23
|
-
return f"🫘 New bag registered: {name}, {total_g:.0f} g. I'll keep count."
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
def bag_status(state) -> str:
|
|
27
|
-
bag = state.get("bag")
|
|
28
|
-
if not bag:
|
|
29
|
-
return "No bag registered. Start one with: /newbag <grams> [name]"
|
|
30
|
-
remaining = max(0.0, bag["total_g"] - bag["used_g"])
|
|
31
|
-
dose = _typical_dose(state)
|
|
32
|
-
doses_left = int(remaining // dose) if dose else 0
|
|
33
|
-
line = (
|
|
34
|
-
f"🫘 {bag['name']}: {remaining:.0f} g of {bag['total_g']:.0f} g left"
|
|
35
|
-
f" (≈{doses_left} doses) · {bag['shots']} shots"
|
|
36
|
-
)
|
|
37
|
-
if bag["shots"] and bag["rating_sum"]:
|
|
38
|
-
line += f" · avg {'★' * round(bag['rating_sum'] / bag['shots'])}"
|
|
39
|
-
return line
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
def track_shot(state, notes: dict) -> str | None:
|
|
43
|
-
"""Subtract the logged dose; returns a warning message when running low."""
|
|
44
|
-
bag = state.get("bag")
|
|
45
|
-
if not bag:
|
|
46
|
-
return None
|
|
47
|
-
try:
|
|
48
|
-
dose = float(notes.get("doseIn", ""))
|
|
49
|
-
except (TypeError, ValueError):
|
|
50
|
-
dose = 0.0
|
|
51
|
-
if dose <= 0:
|
|
52
|
-
return None
|
|
53
|
-
bag["used_g"] += dose
|
|
54
|
-
bag["shots"] += 1
|
|
55
|
-
rating = notes.get("rating")
|
|
56
|
-
if isinstance(rating, int):
|
|
57
|
-
bag["rating_sum"] += rating
|
|
58
|
-
remaining = bag["total_g"] - bag["used_g"]
|
|
59
|
-
|
|
60
|
-
message = None
|
|
61
|
-
if remaining <= 0:
|
|
62
|
-
message = f"🫘 That was the last of '{bag['name']}' by my count — bag empty."
|
|
63
|
-
elif remaining < WARN_DOSES * dose and not bag["warned"]:
|
|
64
|
-
bag["warned"] = True
|
|
65
|
-
message = (
|
|
66
|
-
f"🫘 Heads-up: '{bag['name']}' is down to ~{remaining:.0f} g "
|
|
67
|
-
f"(≈{int(remaining // dose)} doses). Time to restock?"
|
|
68
|
-
)
|
|
69
|
-
state.set("bag", bag)
|
|
70
|
-
return message
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
def _typical_dose(state) -> float:
|
|
74
|
-
try:
|
|
75
|
-
return float(state.get("last_notes", {}).get("doseIn", DEFAULT_DOSE_G))
|
|
76
|
-
except (TypeError, ValueError):
|
|
77
|
-
return DEFAULT_DOSE_G
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|