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
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,14 @@ video with the clip on top and the chart animating below it, playhead
tracking the x axis — and sends it to the chat (ready for sharing). Disable
with `MATEBOT_REEL=0`; it needs ffmpeg and the `plots` extra (matplotlib).

## Troubleshooting

**"That didn't work" on /newbag (or bags/defaults reset on restart)** - the
state dir isn't writable. In Docker the container runs as uid 1000, but a
bind-mounted `./data` created by Docker belongs to root: fix with
`mkdir -p data && sudo chown -R 1000:1000 data` and restart. The bot logs the
exact path at startup when this happens.

## Configuration

Environment variables, or the same keys in `~/.config/matebot/config.toml`:
Expand Down
4 changes: 3 additions & 1 deletion docker-compose.example.yml
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ services:
# --- optional: git-backed shot journal + GitHub Pages site ---
# MATEBOT_DATA_REPO: /journal
volumes:
- ./data:/data # bot state (last shot, defaults)
# bot state (last shot, defaults, bean bags). The container runs as uid
# 1000 - create the dir writable first: mkdir -p data && chown 1000:1000 data
- ./data:/data
# - ./journal:/journal # your data repo (git clone)
# - ~/.ssh:/home/matebot/.ssh:ro # ssh key for git push
6 changes: 6 additions & 0 deletions src/matebot/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,12 @@ async def _run(config: Config, *, replay: str | None, dry_run: bool) -> int:

log = logging.getLogger("matebot")
state = State(pathlib.Path(config.state_dir) / "state.json")
if not state.persistent:
log.error(
"state dir %s is not writable - bags/defaults/resume won't survive "
"restarts (Docker: chown -R 1000:1000 the mounted data dir)",
config.state_dir,
)

async with GaggiMateClient(config.machine_host) as client:
if dry_run:
Expand Down
5 changes: 3 additions & 2 deletions src/matebot/commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -66,9 +66,10 @@ async def handle(self, text: str) -> bool:
await handler()
except MachineError as exc:
await self.messenger.send(f"⚠️ Can't reach the machine ({exc}). Is it plugged in?")
except Exception: # noqa: BLE001
except Exception as exc: # noqa: BLE001
log.exception("command %s failed", cmd)
await self.messenger.send("⚠️ That didn't work — check the logs.")
detail = f"{type(exc).__name__}: {exc}"[:150]
await self.messenger.send(f"⚠️ That didn't work — {detail}")
return True

# ------------------------------------------------------------- commands
Expand Down
33 changes: 32 additions & 1 deletion src/matebot/state.py
Original file line number Diff line number Diff line change
@@ -1,18 +1,27 @@
"""Tiny atomic JSON state file (questionnaire defaults + resume data)."""
"""Tiny atomic JSON state file (questionnaire defaults + resume data).

An unwritable state dir (classic case: a root-owned ``./data`` bind mount
while the Docker image runs as uid 1000) must not take the bot down — state
degrades to in-memory with a loud log line instead of every write raising.
"""

from __future__ import annotations

import json
import logging
import os
import tempfile
from pathlib import Path
from typing import Any

log = logging.getLogger(__name__)


class State:
def __init__(self, path: str | Path) -> None:
self.path = Path(path)
self._data: dict[str, Any] = {}
self._warned = False
if self.path.exists():
try:
self._data = json.loads(self.path.read_text())
Expand All @@ -30,7 +39,29 @@ def update(self, **kwargs: Any) -> None:
self._data.update(kwargs)
self._flush()

@property
def persistent(self) -> bool:
"""True when the state file is actually writable (probes once)."""
try:
self._write()
except OSError:
return False
return True

def _flush(self) -> None:
try:
self._write()
except OSError as exc:
if not self._warned:
self._warned = True
log.error(
"state dir %s is not writable (%s) - keeping state in memory only. "
"If this runs in Docker, make the mounted state dir writable by "
"uid 1000: chown -R 1000:1000 <host dir>",
self.path.parent, exc,
)

def _write(self) -> None:
self.path.parent.mkdir(parents=True, exist_ok=True)
fd, tmp = tempfile.mkstemp(dir=self.path.parent, prefix=".state-")
try:
Expand Down
28 changes: 28 additions & 0 deletions tests/test_state.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
"""State resilience: an unwritable state dir must not break the bot."""

import logging

from matebot.state import State


def test_state_roundtrip(tmp_path):
s = State(tmp_path / "state.json")
s.set("bags", [{"name": "x"}])
assert s.persistent
s2 = State(tmp_path / "state.json")
assert s2.get("bags") == [{"name": "x"}]


def test_unwritable_dir_degrades_to_memory(tmp_path, monkeypatch, caplog):
s = State(tmp_path / "state.json")

def boom(*a, **k):
raise PermissionError(13, "Permission denied")

monkeypatch.setattr("matebot.state.tempfile.mkstemp", boom)
assert not s.persistent
with caplog.at_level(logging.ERROR):
s.set("bags", [{"name": "x"}]) # must not raise
s.set("bags", [{"name": "y"}]) # warning only once
assert s.get("bags") == [{"name": "y"}] # in-memory state still works
assert sum("not writable" in r.message for r in caplog.records) == 1
Loading