matebot 0.2.0__py3-none-any.whl

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.
@@ -0,0 +1,28 @@
1
+ from .base import Event, Messenger, Option, OptionSelected, TextReply
2
+
3
+
4
+ def create_messenger(config) -> Messenger:
5
+ """Instantiate the configured backend (imports lazily: extras are optional)."""
6
+ kind = config.messenger
7
+ if kind == "telegram":
8
+ from .telegram import TelegramMessenger
9
+
10
+ return TelegramMessenger(config.telegram_token, config.telegram_chat_id)
11
+ if kind == "discord":
12
+ from .discord import DiscordMessenger
13
+
14
+ return DiscordMessenger(config.discord_token, config.discord_channel_id)
15
+ raise ValueError(
16
+ f"unknown messenger {kind!r} (supported: telegram, discord; "
17
+ "matrix is planned — see README for WhatsApp options)"
18
+ )
19
+
20
+
21
+ __all__ = [
22
+ "Event",
23
+ "Messenger",
24
+ "Option",
25
+ "OptionSelected",
26
+ "TextReply",
27
+ "create_messenger",
28
+ ]
@@ -0,0 +1,58 @@
1
+ """Messenger abstraction: everything the conversation engine needs, nothing more.
2
+
3
+ A backend renders prompts (text + option buttons where the platform has them),
4
+ and yields user interactions as a single event stream. Option ids stay ≤64
5
+ bytes so they fit Telegram ``callback_data`` and Discord ``custom_id``.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from abc import ABC, abstractmethod
11
+ from collections.abc import AsyncIterator
12
+ from dataclasses import dataclass
13
+
14
+
15
+ @dataclass
16
+ class Option:
17
+ id: str # e.g. "g|59|r|4"
18
+ label: str # e.g. "★★★★"
19
+
20
+
21
+ @dataclass
22
+ class OptionSelected:
23
+ option_id: str
24
+
25
+
26
+ @dataclass
27
+ class TextReply:
28
+ text: str
29
+
30
+
31
+ Event = OptionSelected | TextReply
32
+
33
+
34
+ class Messenger(ABC):
35
+ """One chat/channel with one user; the espresso machine's voice."""
36
+
37
+ @abstractmethod
38
+ async def start(self) -> None: ...
39
+
40
+ @abstractmethod
41
+ async def stop(self) -> None: ...
42
+
43
+ @abstractmethod
44
+ async def send(self, text: str, options: list[Option] | None = None) -> str:
45
+ """Send a message, return an opaque reference usable with edit()."""
46
+
47
+ @abstractmethod
48
+ async def edit(self, ref: str, text: str, options: list[Option] | None = None) -> None:
49
+ """Replace a previously sent message's text/options (best effort)."""
50
+
51
+ async def send_photo(self, image: bytes, caption: str) -> str:
52
+ """Send an image with caption; backends without photo support fall
53
+ back to the caption text."""
54
+ return await self.send(caption)
55
+
56
+ @abstractmethod
57
+ def events(self) -> AsyncIterator[Event]:
58
+ """User interactions, in order. Runs until stop()."""
@@ -0,0 +1,88 @@
1
+ """Discord backend (discord.py v2: gateway websocket + button Views)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import logging
7
+ from collections.abc import AsyncIterator
8
+
9
+ from .base import Event, Messenger, Option, OptionSelected, TextReply
10
+
11
+ log = logging.getLogger(__name__)
12
+
13
+
14
+ class DiscordMessenger(Messenger):
15
+ def __init__(self, token: str, channel_id: int | str) -> None:
16
+ import discord
17
+
18
+ self.token = token
19
+ self.channel_id = int(channel_id)
20
+ self._queue: asyncio.Queue[Event] = asyncio.Queue()
21
+ self._ready = asyncio.Event()
22
+ self._task: asyncio.Task | None = None
23
+
24
+ intents = discord.Intents.default()
25
+ intents.message_content = True
26
+ self.client = discord.Client(intents=intents)
27
+
28
+ @self.client.event
29
+ async def on_ready():
30
+ self._ready.set()
31
+ log.info("discord messenger ready in channel %s", self.channel_id)
32
+
33
+ @self.client.event
34
+ async def on_message(message):
35
+ if message.author == self.client.user or message.channel.id != self.channel_id:
36
+ return
37
+ await self._queue.put(TextReply(message.content))
38
+
39
+ @self.client.event
40
+ async def on_interaction(interaction):
41
+ if interaction.type == discord.InteractionType.component:
42
+ await interaction.response.defer()
43
+ await self._queue.put(OptionSelected(interaction.data["custom_id"]))
44
+
45
+ async def start(self) -> None:
46
+ self._task = asyncio.create_task(self.client.start(self.token))
47
+ await asyncio.wait_for(self._ready.wait(), timeout=60)
48
+
49
+ async def stop(self) -> None:
50
+ await self.client.close()
51
+ if self._task:
52
+ self._task.cancel()
53
+
54
+ def _view(self, options: list[Option] | None):
55
+ if not options:
56
+ return None
57
+ import discord
58
+
59
+ view = discord.ui.View(timeout=None)
60
+ for o in options:
61
+ view.add_item(discord.ui.Button(label=o.label, custom_id=o.id))
62
+ return view
63
+
64
+ async def send(self, text: str, options: list[Option] | None = None) -> str:
65
+ channel = self.client.get_channel(self.channel_id)
66
+ msg = await channel.send(text, view=self._view(options))
67
+ return str(msg.id)
68
+
69
+ async def send_photo(self, image: bytes, caption: str) -> str:
70
+ import io as _io
71
+
72
+ import discord
73
+
74
+ channel = self.client.get_channel(self.channel_id)
75
+ msg = await channel.send(caption, file=discord.File(_io.BytesIO(image), "shot.png"))
76
+ return str(msg.id)
77
+
78
+ async def edit(self, ref: str, text: str, options: list[Option] | None = None) -> None:
79
+ try:
80
+ channel = self.client.get_channel(self.channel_id)
81
+ msg = await channel.fetch_message(int(ref))
82
+ await msg.edit(content=text, view=self._view(options))
83
+ except Exception as exc: # noqa: BLE001 - edits are cosmetic
84
+ log.debug("edit failed: %s", exc)
85
+
86
+ async def events(self) -> AsyncIterator[Event]:
87
+ while True:
88
+ yield await self._queue.get()
@@ -0,0 +1,118 @@
1
+ """Telegram backend (python-telegram-bot v20+ async API, long-polling)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import contextlib
7
+ import logging
8
+ from collections.abc import AsyncIterator
9
+
10
+ from .base import Event, Messenger, Option, OptionSelected, TextReply
11
+
12
+ log = logging.getLogger(__name__)
13
+
14
+
15
+ class TelegramMessenger(Messenger):
16
+ def __init__(self, token: str, chat_id: int | str) -> None:
17
+ from telegram.ext import (
18
+ Application,
19
+ CallbackQueryHandler,
20
+ MessageHandler,
21
+ filters,
22
+ )
23
+
24
+ self.chat_id = int(chat_id)
25
+ self._queue: asyncio.Queue[Event] = asyncio.Queue()
26
+ self.app = Application.builder().token(token).build()
27
+ self.app.add_handler(CallbackQueryHandler(self._on_callback))
28
+ self.app.add_handler(
29
+ MessageHandler(filters.TEXT & filters.Chat(self.chat_id), self._on_text)
30
+ )
31
+
32
+ async def _on_callback(self, update, context) -> None:
33
+ query = update.callback_query
34
+ await query.answer()
35
+ if query.message and query.message.chat_id != self.chat_id:
36
+ return
37
+ await self._queue.put(OptionSelected(query.data))
38
+
39
+ async def _on_text(self, update, context) -> None:
40
+ await self._queue.put(TextReply(update.message.text))
41
+
42
+ async def start(self) -> None:
43
+ from telegram import BotCommand
44
+
45
+ await self.app.initialize()
46
+ await self.app.start()
47
+ with contextlib.suppress(Exception): # menu is cosmetic
48
+ await self.app.bot.set_my_commands([
49
+ BotCommand("wake", "turn the machine on, ping when hot"),
50
+ BotCommand("sleep", "machine to standby"),
51
+ BotCommand("status", "mode, temperature, connectivity"),
52
+ BotCommand("last", "last logged shot"),
53
+ BotCommand("fix", "redo the last shot's log"),
54
+ BotCommand("bag", "how much is left in the bean bag"),
55
+ BotCommand("newbag", "start tracking a bean bag"),
56
+ BotCommand("help", "list commands"),
57
+ ])
58
+ await self.app.updater.start_polling(drop_pending_updates=True)
59
+ log.info("telegram messenger polling as chat %s", self.chat_id)
60
+
61
+ async def stop(self) -> None:
62
+ await self.app.updater.stop()
63
+ await self.app.stop()
64
+ await self.app.shutdown()
65
+
66
+ def _keyboard(self, options: list[Option] | None):
67
+ if not options:
68
+ return None
69
+ from telegram import InlineKeyboardButton, InlineKeyboardMarkup
70
+
71
+ buttons = [InlineKeyboardButton(o.label, callback_data=o.id) for o in options]
72
+ # ratings in rows of three (a single row of five truncates on phones),
73
+ # everything else stacked
74
+ if all(o.id.split("|")[2] == "r" for o in options):
75
+ rows = [buttons[i : i + 3] for i in range(0, len(buttons), 3)]
76
+ else:
77
+ rows = [[b] for b in buttons]
78
+ return InlineKeyboardMarkup(rows)
79
+
80
+ async def send(self, text: str, options: list[Option] | None = None) -> str:
81
+ # telegram API connectivity can flap; a lost send must not crash the bot
82
+ last_exc = None
83
+ for attempt in range(5):
84
+ try:
85
+ msg = await self.app.bot.send_message(
86
+ self.chat_id, text, reply_markup=self._keyboard(options)
87
+ )
88
+ return str(msg.message_id)
89
+ except Exception as exc: # noqa: BLE001 - NetworkError et al.
90
+ last_exc = exc
91
+ log.warning("send attempt %d failed: %s", attempt + 1, exc)
92
+ await asyncio.sleep(2 * (attempt + 1))
93
+ raise last_exc
94
+
95
+ async def send_photo(self, image: bytes, caption: str) -> str:
96
+ last_exc = None
97
+ for attempt in range(5):
98
+ try:
99
+ msg = await self.app.bot.send_photo(self.chat_id, photo=image, caption=caption)
100
+ return str(msg.message_id)
101
+ except Exception as exc: # noqa: BLE001
102
+ last_exc = exc
103
+ log.warning("photo send attempt %d failed: %s", attempt + 1, exc)
104
+ await asyncio.sleep(2 * (attempt + 1))
105
+ raise last_exc
106
+
107
+ async def edit(self, ref: str, text: str, options: list[Option] | None = None) -> None:
108
+ try:
109
+ await self.app.bot.edit_message_text(
110
+ text, chat_id=self.chat_id, message_id=int(ref),
111
+ reply_markup=self._keyboard(options),
112
+ )
113
+ except Exception as exc: # noqa: BLE001 - edits are cosmetic
114
+ log.debug("edit failed: %s", exc)
115
+
116
+ async def events(self) -> AsyncIterator[Event]:
117
+ while True:
118
+ yield await self._queue.get()
matebot/plot.py ADDED
@@ -0,0 +1,100 @@
1
+ """Render a shot as a PNG chart (sent as the post-shot photo).
2
+
3
+ Same visual language as the journal and the GaggiMate web UI: temperature on
4
+ the left axis, pressure/flow on the right, weight on its own scale, dashed
5
+ targets, phase markers. matplotlib is an optional dependency (``[plots]``
6
+ extra) — callers fall back to a text summary when it is missing.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import io
12
+
13
+ from .slog import Shot
14
+
15
+ # Same palette as the GaggiMate web UI shot chart (design match, no code copied)
16
+ COLORS = {
17
+ "temp": "#F0561D",
18
+ "target_temp": "#731F00",
19
+ "press": "#0066CC",
20
+ "flow": "#63993D",
21
+ "puck": "#204D00",
22
+ "weight": "#8B5CF6",
23
+ "wflow": "#4b2e8d",
24
+ "phase": "#6B7280",
25
+ }
26
+
27
+
28
+ def render_shot_png(shot: Shot, *, title: str | None = None) -> bytes:
29
+ import matplotlib
30
+
31
+ matplotlib.use("Agg")
32
+ import matplotlib.pyplot as plt
33
+
34
+ t = shot.times_s
35
+ s = shot.series
36
+ have = lambda k: k in s and any(s[k]) # noqa: E731
37
+
38
+ fig, ax_temp = plt.subplots(figsize=(8, 4.5), dpi=110)
39
+ ax_bar = ax_temp.twinx()
40
+
41
+ dash = (0, (4, 4))
42
+ if have("ct"):
43
+ ax_temp.plot(t, s["ct"], color=COLORS["temp"], lw=2.2, label="Temp")
44
+ if have("tt"):
45
+ ax_temp.plot(t, s["tt"], color=COLORS["target_temp"], lw=1.4, ls=dash)
46
+ if have("cp"):
47
+ ax_bar.plot(t, s["cp"], color=COLORS["press"], lw=2.2, label="Pressure")
48
+ if have("tp"):
49
+ ax_bar.plot(t, s["tp"], color=COLORS["press"], lw=1.4, ls=dash, alpha=0.8)
50
+ if have("fl"):
51
+ ax_bar.plot(t, s["fl"], color=COLORS["flow"], lw=1.8, label="Pump flow")
52
+ if have("tf"):
53
+ ax_bar.plot(t, s["tf"], color=COLORS["flow"], lw=1.4, ls=dash, alpha=0.8)
54
+ if have("pf"):
55
+ ax_bar.plot(t, s["pf"], color=COLORS["puck"], lw=1.8, label="Puck flow")
56
+
57
+ weight = s.get("v") if have("v") else s.get("ev") if have("ev") else None
58
+ if weight:
59
+ ax_g = ax_temp.twinx()
60
+ ax_g.spines.right.set_position(("axes", 1.12))
61
+ ax_g.plot(t, weight, color=COLORS["weight"], lw=2.2, label="Weight")
62
+ ax_g.set_ylabel("g", color=COLORS["weight"])
63
+ ax_g.set_ylim(bottom=0)
64
+ ax_g.tick_params(axis="y", colors=COLORS["weight"], labelsize=8)
65
+
66
+ for phase in shot.phases:
67
+ px = phase.sample_index * shot.sample_interval_ms / 1000
68
+ if 0.3 < px < (t[-1] if t else 0) - 0.3:
69
+ ax_temp.axvline(px, color=COLORS["phase"], lw=1.0, alpha=0.8)
70
+ ax_temp.annotate(
71
+ phase.name[:18], (px, 0.99), xycoords=("data", "axes fraction"),
72
+ rotation=90, va="top", ha="right", fontsize=6.5, color="white",
73
+ bbox={"boxstyle": "round,pad=0.25", "facecolor": "#162132",
74
+ "alpha": 0.75, "edgecolor": "none"},
75
+ )
76
+
77
+ ax_temp.set_xlabel("s", fontsize=8)
78
+ ax_temp.set_ylabel("°C", color=COLORS["temp"], fontsize=9)
79
+ ax_temp.tick_params(labelsize=8)
80
+ ax_temp.tick_params(axis="y", colors=COLORS["temp"])
81
+ ax_bar.set_ylabel("bar · g/s", color=COLORS["press"], fontsize=9)
82
+ ax_bar.tick_params(axis="y", colors=COLORS["press"], labelsize=8)
83
+ ax_bar.set_ylim(0, 16)
84
+ ax_temp.grid(True, axis="y", lw=0.3, alpha=0.4)
85
+ if title:
86
+ ax_temp.set_title(title, fontsize=10)
87
+
88
+ handles, labels = [], []
89
+ for ax in fig.axes:
90
+ h, lab = ax.get_legend_handles_labels()
91
+ handles += h
92
+ labels += lab
93
+ fig.legend(handles, labels, loc="lower center", ncol=len(labels),
94
+ bbox_to_anchor=(0.5, 0.0), fontsize=7.5, frameon=False)
95
+
96
+ fig.tight_layout(rect=(0, 0.08, 1, 1))
97
+ buf = io.BytesIO()
98
+ fig.savefig(buf, format="png")
99
+ plt.close(fig)
100
+ return buf.getvalue()
matebot/sitegen.py ADDED
@@ -0,0 +1,119 @@
1
+ """Generate the static shot-explorer site (GitHub Pages friendly).
2
+
3
+ Input: a folder of ``NNNNNN.slog`` + optional ``NNNNNN.json`` notes files
4
+ (the layout of a matebot/GaggiMate data repo's ``shots/`` dir).
5
+ Output: ``docs/`` with a self-contained viewer (no CDN):
6
+ index.html + app.js + style.css + index.json + shots/<id>.json
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import importlib.resources
12
+ import json
13
+ import logging
14
+ from pathlib import Path
15
+
16
+ from .slog import Shot, SlogError, parse_slog
17
+
18
+ log = logging.getLogger(__name__)
19
+
20
+ WEB_ASSETS = (
21
+ "index.html",
22
+ "app.js",
23
+ "style.css",
24
+ "vendor/chart.umd.js",
25
+ "vendor/chartjs-plugin-annotation.min.js",
26
+ )
27
+
28
+
29
+ def _round(values: list[float], digits: int) -> list[float]:
30
+ return [round(v, digits) for v in values]
31
+
32
+
33
+ def _shot_payload(shot: Shot, notes: dict | None) -> dict:
34
+ s = shot.series
35
+ step = shot.sample_interval_ms / 1000.0
36
+ return {
37
+ "header": {
38
+ "profile": shot.profile_name,
39
+ "ts": shot.start_epoch,
40
+ "duration_s": round(shot.duration_ms / 1000, 1),
41
+ "final_g": shot.final_weight_g,
42
+ "phases": [
43
+ {"t": round(p.sample_index * step, 2), "name": p.name} for p in shot.phases
44
+ ],
45
+ },
46
+ "series": {
47
+ "t": _round([t * step for t in s.get("t", [])], 2),
48
+ "ct": _round(s.get("ct", []), 1),
49
+ "tt": _round(s.get("tt", []), 1),
50
+ "cp": _round(s.get("cp", []), 2),
51
+ "tp": _round(s.get("tp", []), 2),
52
+ "fl": _round(s.get("fl", []), 2),
53
+ "tf": _round(s.get("tf", []), 2),
54
+ "pf": _round(s.get("pf", []), 2),
55
+ "vf": _round(s.get("vf", []), 2),
56
+ "v": _round(s.get("v", []), 1),
57
+ "ev": _round(s.get("ev", []), 1),
58
+ },
59
+ "notes": notes or {},
60
+ }
61
+
62
+
63
+ def _load_notes(path: Path) -> dict | None:
64
+ try:
65
+ raw = path.read_bytes()
66
+ except OSError:
67
+ return None
68
+ if not raw.lstrip().startswith(b"{"): # gzip/HTML masquerade, see machine.py
69
+ log.warning("skipping non-JSON notes file %s", path.name)
70
+ return None
71
+ try:
72
+ return json.loads(raw)
73
+ except json.JSONDecodeError:
74
+ return None
75
+
76
+
77
+ def generate(shots_dir: str | Path, out_dir: str | Path, *, title: str = "Shot Journal") -> int:
78
+ """Build the site; returns the number of shots included."""
79
+ shots_dir, out_dir = Path(shots_dir), Path(out_dir)
80
+ (out_dir / "shots").mkdir(parents=True, exist_ok=True)
81
+
82
+ index = []
83
+ for slog_path in sorted(shots_dir.glob("*.slog")):
84
+ try:
85
+ shot = parse_slog(slog_path.read_bytes())
86
+ except SlogError as exc:
87
+ log.warning("skipping %s: %s", slog_path.name, exc)
88
+ continue
89
+ sid = slog_path.stem
90
+ notes = _load_notes(slog_path.with_suffix(".json"))
91
+ payload = _shot_payload(shot, notes)
92
+ (out_dir / "shots" / f"{sid}.json").write_text(
93
+ json.dumps(payload, separators=(",", ":"))
94
+ )
95
+ cp = payload["series"]["cp"]
96
+ index.append(
97
+ {
98
+ "id": sid,
99
+ "ts": shot.start_epoch,
100
+ "duration_s": payload["header"]["duration_s"],
101
+ "profile": shot.profile_name,
102
+ "final_g": shot.final_weight_g,
103
+ "peak_bar": max(cp) if cp else 0,
104
+ "rating": (notes or {}).get("rating") or 0,
105
+ "bean": (notes or {}).get("beanType", ""),
106
+ "ratio": (notes or {}).get("ratio", ""),
107
+ "taste": (notes or {}).get("balanceTaste", ""),
108
+ }
109
+ )
110
+
111
+ index.sort(key=lambda e: e["id"], reverse=True)
112
+ (out_dir / "index.json").write_text(json.dumps({"title": title, "shots": index}))
113
+
114
+ web = importlib.resources.files("matebot") / "web"
115
+ (out_dir / "vendor").mkdir(exist_ok=True)
116
+ for name in WEB_ASSETS:
117
+ (out_dir / name).write_text((web / name).read_text())
118
+ log.info("site generated: %d shots -> %s", len(index), out_dir)
119
+ return len(index)