matebot 0.2.1__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.
Files changed (55) hide show
  1. {matebot-0.2.1 → matebot-0.2.2}/AGENTS.md +1 -1
  2. {matebot-0.2.1 → matebot-0.2.2}/PKG-INFO +1 -1
  3. {matebot-0.2.1 → matebot-0.2.2}/flake.nix +1 -1
  4. {matebot-0.2.1 → matebot-0.2.2}/pyproject.toml +1 -1
  5. {matebot-0.2.1 → matebot-0.2.2}/src/matebot/__init__.py +1 -1
  6. matebot-0.2.2/src/matebot/bags.py +134 -0
  7. {matebot-0.2.1 → matebot-0.2.2}/src/matebot/commands.py +7 -1
  8. {matebot-0.2.1 → matebot-0.2.2}/src/matebot/conversation.py +7 -0
  9. {matebot-0.2.1 → matebot-0.2.2}/src/matebot/messengers/telegram.py +1 -0
  10. {matebot-0.2.1 → matebot-0.2.2}/tests/test_bags.py +34 -0
  11. {matebot-0.2.1 → matebot-0.2.2}/tests/test_conversation.py +23 -0
  12. matebot-0.2.1/src/matebot/bags.py +0 -77
  13. {matebot-0.2.1 → matebot-0.2.2}/.github/workflows/ci.yml +0 -0
  14. {matebot-0.2.1 → matebot-0.2.2}/.github/workflows/docker.yml +0 -0
  15. {matebot-0.2.1 → matebot-0.2.2}/.github/workflows/release.yml +0 -0
  16. {matebot-0.2.1 → matebot-0.2.2}/.gitignore +0 -0
  17. {matebot-0.2.1 → matebot-0.2.2}/CLAUDE.md +0 -0
  18. {matebot-0.2.1 → matebot-0.2.2}/Dockerfile +0 -0
  19. {matebot-0.2.1 → matebot-0.2.2}/LICENSE +0 -0
  20. {matebot-0.2.1 → matebot-0.2.2}/README.md +0 -0
  21. {matebot-0.2.1 → matebot-0.2.2}/docker-compose.example.yml +0 -0
  22. {matebot-0.2.1 → matebot-0.2.2}/docs/screenshots/journal-detail.png +0 -0
  23. {matebot-0.2.1 → matebot-0.2.2}/docs/screenshots/journal-list.png +0 -0
  24. {matebot-0.2.1 → matebot-0.2.2}/docs/screenshots/telegram-chat.png +0 -0
  25. {matebot-0.2.1 → matebot-0.2.2}/flake.lock +0 -0
  26. {matebot-0.2.1 → matebot-0.2.2}/src/matebot/cli.py +0 -0
  27. {matebot-0.2.1 → matebot-0.2.2}/src/matebot/config.py +0 -0
  28. {matebot-0.2.1 → matebot-0.2.2}/src/matebot/digest.py +0 -0
  29. {matebot-0.2.1 → matebot-0.2.2}/src/matebot/hints.py +0 -0
  30. {matebot-0.2.1 → matebot-0.2.2}/src/matebot/machine.py +0 -0
  31. {matebot-0.2.1 → matebot-0.2.2}/src/matebot/messengers/__init__.py +0 -0
  32. {matebot-0.2.1 → matebot-0.2.2}/src/matebot/messengers/base.py +0 -0
  33. {matebot-0.2.1 → matebot-0.2.2}/src/matebot/messengers/discord.py +0 -0
  34. {matebot-0.2.1 → matebot-0.2.2}/src/matebot/plot.py +0 -0
  35. {matebot-0.2.1 → matebot-0.2.2}/src/matebot/sitegen.py +0 -0
  36. {matebot-0.2.1 → matebot-0.2.2}/src/matebot/slog.py +0 -0
  37. {matebot-0.2.1 → matebot-0.2.2}/src/matebot/state.py +0 -0
  38. {matebot-0.2.1 → matebot-0.2.2}/src/matebot/sync.py +0 -0
  39. {matebot-0.2.1 → matebot-0.2.2}/src/matebot/watcher.py +0 -0
  40. {matebot-0.2.1 → matebot-0.2.2}/src/matebot/web/app.js +0 -0
  41. {matebot-0.2.1 → matebot-0.2.2}/src/matebot/web/index.html +0 -0
  42. {matebot-0.2.1 → matebot-0.2.2}/src/matebot/web/style.css +0 -0
  43. {matebot-0.2.1 → matebot-0.2.2}/src/matebot/web/vendor/chart.umd.js +0 -0
  44. {matebot-0.2.1 → matebot-0.2.2}/src/matebot/web/vendor/chartjs-plugin-annotation.min.js +0 -0
  45. {matebot-0.2.1 → matebot-0.2.2}/tests/fixtures/000004.slog +0 -0
  46. {matebot-0.2.1 → matebot-0.2.2}/tests/fixtures/status_frames.jsonl +0 -0
  47. {matebot-0.2.1 → matebot-0.2.2}/tests/test_cleaning.py +0 -0
  48. {matebot-0.2.1 → matebot-0.2.2}/tests/test_commands.py +0 -0
  49. {matebot-0.2.1 → matebot-0.2.2}/tests/test_digest.py +0 -0
  50. {matebot-0.2.1 → matebot-0.2.2}/tests/test_hints.py +0 -0
  51. {matebot-0.2.1 → matebot-0.2.2}/tests/test_plot.py +0 -0
  52. {matebot-0.2.1 → matebot-0.2.2}/tests/test_sitegen.py +0 -0
  53. {matebot-0.2.1 → matebot-0.2.2}/tests/test_slog.py +0 -0
  54. {matebot-0.2.1 → matebot-0.2.2}/tests/test_sync_retry.py +0 -0
  55. {matebot-0.2.1 → matebot-0.2.2}/tests/test_watcher.py +0 -0
@@ -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.1
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
@@ -13,7 +13,7 @@
13
13
  default = matebot;
14
14
  matebot = pkgs.python3Packages.buildPythonApplication {
15
15
  pname = "matebot";
16
- version = "0.2.1";
16
+ version = "0.2.2";
17
17
  pyproject = true;
18
18
  src = self;
19
19
  build-system = [ pkgs.python3Packages.hatchling ];
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "matebot"
7
- version = "0.2.1"
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"
@@ -1,3 +1,3 @@
1
1
  """matebot — the proactive companion for GaggiMate espresso machines."""
2
2
 
3
- __version__ = "0.2.1"
3
+ __version__ = "0.2.2"
@@ -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 bag\n"
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