feat: interaction / event logging
New `events` table with (user_id, kind, created_at, data JSON). Instruments: Bot: - cmd.start, cmd.history, cmd.stats, cmd.delete, cmd.export, cmd.feedback - workout.save (source=text), workout.delete (source=bot) Server: - workout.save (source=webapp), workout.update, workout.delete (source=webapp) - POST /api/events for Mini App client-side events Mini App: - miniapp.open on init() - set.add on addSet(), with exercise name / reps / weight (per-set timestamps unlock the rest-timer feature later) log_event swallows failures so it can never break a caller. get_events supports user_id / kind filtering for inspection. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
1d3e7d5e80
commit
52277e99de
5 changed files with 187 additions and 2 deletions
15
bot.py
15
bot.py
|
|
@ -16,7 +16,7 @@ from telegram.ext import (
|
||||||
filters,
|
filters,
|
||||||
)
|
)
|
||||||
|
|
||||||
from db import init_db, save_workout, get_workouts, get_workout_count, get_stats_sql, delete_workout, save_feedback, get_user_workout_number, resolve_user_number
|
from db import init_db, save_workout, get_workouts, get_workout_count, get_stats_sql, delete_workout, save_feedback, get_user_workout_number, resolve_user_number, log_event
|
||||||
from parser import parse_workout, format_workout
|
from parser import parse_workout, format_workout
|
||||||
|
|
||||||
load_dotenv()
|
load_dotenv()
|
||||||
|
|
@ -78,6 +78,7 @@ def extract_timestamp(update: Update) -> tuple[datetime, bool]:
|
||||||
|
|
||||||
|
|
||||||
async def cmd_start(update: Update, context: ContextTypes.DEFAULT_TYPE):
|
async def cmd_start(update: Update, context: ContextTypes.DEFAULT_TYPE):
|
||||||
|
log_event(update.effective_user.id, "cmd.start")
|
||||||
text = (
|
text = (
|
||||||
"\U0001f4aa <b>Fitness Tracker Bot</b>\n\n"
|
"\U0001f4aa <b>Fitness Tracker Bot</b>\n\n"
|
||||||
"Send me your workout and I'll save it!\n\n"
|
"Send me your workout and I'll save it!\n\n"
|
||||||
|
|
@ -112,6 +113,7 @@ async def cmd_start(update: Update, context: ContextTypes.DEFAULT_TYPE):
|
||||||
|
|
||||||
async def cmd_history(update: Update, context: ContextTypes.DEFAULT_TYPE):
|
async def cmd_history(update: Update, context: ContextTypes.DEFAULT_TYPE):
|
||||||
user_id = update.effective_user.id
|
user_id = update.effective_user.id
|
||||||
|
log_event(user_id, "cmd.history")
|
||||||
workouts = get_workouts(user_id, limit=5)
|
workouts = get_workouts(user_id, limit=5)
|
||||||
|
|
||||||
if not workouts:
|
if not workouts:
|
||||||
|
|
@ -134,6 +136,7 @@ async def cmd_history(update: Update, context: ContextTypes.DEFAULT_TYPE):
|
||||||
|
|
||||||
async def cmd_stats(update: Update, context: ContextTypes.DEFAULT_TYPE):
|
async def cmd_stats(update: Update, context: ContextTypes.DEFAULT_TYPE):
|
||||||
user_id = update.effective_user.id
|
user_id = update.effective_user.id
|
||||||
|
log_event(user_id, "cmd.stats")
|
||||||
stats = get_stats_sql(user_id)
|
stats = get_stats_sql(user_id)
|
||||||
|
|
||||||
if stats["total_workouts"] == 0:
|
if stats["total_workouts"] == 0:
|
||||||
|
|
@ -152,6 +155,7 @@ async def cmd_stats(update: Update, context: ContextTypes.DEFAULT_TYPE):
|
||||||
|
|
||||||
async def cmd_delete(update: Update, context: ContextTypes.DEFAULT_TYPE):
|
async def cmd_delete(update: Update, context: ContextTypes.DEFAULT_TYPE):
|
||||||
user_id = update.effective_user.id
|
user_id = update.effective_user.id
|
||||||
|
log_event(user_id, "cmd.delete", {"args": context.args or []})
|
||||||
|
|
||||||
if not context.args:
|
if not context.args:
|
||||||
await update.message.reply_text(
|
await update.message.reply_text(
|
||||||
|
|
@ -169,6 +173,7 @@ async def cmd_delete(update: Update, context: ContextTypes.DEFAULT_TYPE):
|
||||||
|
|
||||||
workout_id = resolve_user_number(user_id, user_number)
|
workout_id = resolve_user_number(user_id, user_number)
|
||||||
if workout_id is not None and delete_workout(user_id, workout_id):
|
if workout_id is not None and delete_workout(user_id, workout_id):
|
||||||
|
log_event(user_id, "workout.delete", {"workout_id": workout_id, "user_number": user_number})
|
||||||
await update.message.reply_text(f"\U0001f5d1 Workout #{user_number} deleted.")
|
await update.message.reply_text(f"\U0001f5d1 Workout #{user_number} deleted.")
|
||||||
else:
|
else:
|
||||||
await update.message.reply_text(
|
await update.message.reply_text(
|
||||||
|
|
@ -183,6 +188,7 @@ async def cmd_export(update: Update, context: ContextTypes.DEFAULT_TYPE):
|
||||||
from db import export_workouts
|
from db import export_workouts
|
||||||
|
|
||||||
user_id = update.effective_user.id
|
user_id = update.effective_user.id
|
||||||
|
log_event(user_id, "cmd.export")
|
||||||
data = export_workouts(user_id)
|
data = export_workouts(user_id)
|
||||||
|
|
||||||
if not data:
|
if not data:
|
||||||
|
|
@ -211,6 +217,7 @@ async def cmd_feedback(update: Update, context: ContextTypes.DEFAULT_TYPE):
|
||||||
return
|
return
|
||||||
|
|
||||||
save_feedback(user_id, text)
|
save_feedback(user_id, text)
|
||||||
|
log_event(user_id, "cmd.feedback")
|
||||||
await update.message.reply_text("\U0001f4dd Feedback saved, thanks!")
|
await update.message.reply_text("\U0001f4dd Feedback saved, thanks!")
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -249,6 +256,12 @@ async def handle_message(update: Update, context: ContextTypes.DEFAULT_TYPE):
|
||||||
superset_dicts = [[ex.to_dict() for ex in group] for group in groups]
|
superset_dicts = [[ex.to_dict() for ex in group] for group in groups]
|
||||||
workout_id = save_workout(user_id, timestamp, superset_dicts, raw_text=text)
|
workout_id = save_workout(user_id, timestamp, superset_dicts, raw_text=text)
|
||||||
user_number = get_user_workout_number(user_id, workout_id) or workout_id
|
user_number = get_user_workout_number(user_id, workout_id) or workout_id
|
||||||
|
log_event(user_id, "workout.save", {
|
||||||
|
"source": "text",
|
||||||
|
"workout_id": workout_id,
|
||||||
|
"user_number": user_number,
|
||||||
|
"forwarded": is_forwarded,
|
||||||
|
})
|
||||||
|
|
||||||
# Count totals for the confirmation
|
# Count totals for the confirmation
|
||||||
total_exercises = sum(len(g) for g in groups)
|
total_exercises = sum(len(g) for g in groups)
|
||||||
|
|
|
||||||
61
db.py
61
db.py
|
|
@ -67,6 +67,19 @@ def init_db():
|
||||||
text TEXT NOT NULL,
|
text TEXT NOT NULL,
|
||||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||||
);
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS events (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
user_id INTEGER,
|
||||||
|
kind TEXT NOT NULL,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
data TEXT -- optional JSON payload
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_events_user_created
|
||||||
|
ON events(user_id, created_at);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_events_kind_created
|
||||||
|
ON events(kind, created_at);
|
||||||
""")
|
""")
|
||||||
|
|
||||||
# Migrations
|
# Migrations
|
||||||
|
|
@ -312,6 +325,54 @@ def export_workouts(user_id: int) -> list[dict]:
|
||||||
return [dict(r) for r in rows]
|
return [dict(r) for r in rows]
|
||||||
|
|
||||||
|
|
||||||
|
def log_event(user_id: int | None, kind: str, data: dict | None = None) -> int:
|
||||||
|
"""Record a user event for audit / telemetry. Failures are swallowed so
|
||||||
|
logging never breaks a caller."""
|
||||||
|
try:
|
||||||
|
with get_db() as conn:
|
||||||
|
cur = conn.execute(
|
||||||
|
"INSERT INTO events (user_id, kind, data) VALUES (?, ?, ?)",
|
||||||
|
(user_id, kind, json.dumps(data) if data else None),
|
||||||
|
)
|
||||||
|
return cur.lastrowid
|
||||||
|
except Exception:
|
||||||
|
return -1
|
||||||
|
|
||||||
|
|
||||||
|
def get_events(
|
||||||
|
user_id: int | None = None,
|
||||||
|
kind: str | None = None,
|
||||||
|
limit: int = 100,
|
||||||
|
) -> list[dict]:
|
||||||
|
"""Fetch events, newest first. Filter by user_id and/or kind if given."""
|
||||||
|
where = []
|
||||||
|
params: list = []
|
||||||
|
if user_id is not None:
|
||||||
|
where.append("user_id = ?")
|
||||||
|
params.append(user_id)
|
||||||
|
if kind is not None:
|
||||||
|
where.append("kind = ?")
|
||||||
|
params.append(kind)
|
||||||
|
sql = "SELECT id, user_id, kind, created_at, data FROM events"
|
||||||
|
if where:
|
||||||
|
sql += " WHERE " + " AND ".join(where)
|
||||||
|
sql += " ORDER BY created_at DESC, id DESC LIMIT ?"
|
||||||
|
params.append(limit)
|
||||||
|
|
||||||
|
with get_db() as conn:
|
||||||
|
rows = conn.execute(sql, params).fetchall()
|
||||||
|
out = []
|
||||||
|
for r in rows:
|
||||||
|
d = dict(r)
|
||||||
|
if d.get("data"):
|
||||||
|
try:
|
||||||
|
d["data"] = json.loads(d["data"])
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
pass
|
||||||
|
out.append(d)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
def save_feedback(user_id: int, text: str) -> int:
|
def save_feedback(user_id: int, text: str) -> int:
|
||||||
"""Save user feedback. Returns the feedback id."""
|
"""Save user feedback. Returns the feedback id."""
|
||||||
with get_db() as conn:
|
with get_db() as conn:
|
||||||
|
|
|
||||||
32
server.py
32
server.py
|
|
@ -17,7 +17,7 @@ from urllib.parse import parse_qs
|
||||||
|
|
||||||
from aiohttp import web
|
from aiohttp import web
|
||||||
|
|
||||||
from db import init_db, save_workout, get_workouts, get_workout_count, get_stats_sql, delete_workout, update_workout, export_workouts, get_user_workout_number, get_all_exercise_names
|
from db import init_db, save_workout, get_workouts, get_workout_count, get_stats_sql, delete_workout, update_workout, export_workouts, get_user_workout_number, get_all_exercise_names, log_event
|
||||||
from parser import parse_workout, format_workout
|
from parser import parse_workout, format_workout
|
||||||
|
|
||||||
logging.basicConfig(
|
logging.basicConfig(
|
||||||
|
|
@ -228,6 +228,11 @@ async def api_save_workout(request: web.Request):
|
||||||
)
|
)
|
||||||
|
|
||||||
user_number = get_user_workout_number(request["user_id"], workout_id)
|
user_number = get_user_workout_number(request["user_id"], workout_id)
|
||||||
|
log_event(request["user_id"], "workout.save", {
|
||||||
|
"source": "webapp",
|
||||||
|
"workout_id": workout_id,
|
||||||
|
"user_number": user_number,
|
||||||
|
})
|
||||||
return web.json_response(
|
return web.json_response(
|
||||||
{"workout_id": workout_id, "user_number": user_number},
|
{"workout_id": workout_id, "user_number": user_number},
|
||||||
status=201,
|
status=201,
|
||||||
|
|
@ -249,6 +254,11 @@ async def api_update_workout(request: web.Request):
|
||||||
if new_id is None:
|
if new_id is None:
|
||||||
return web.json_response({"error": "Not found"}, status=404)
|
return web.json_response({"error": "Not found"}, status=404)
|
||||||
user_number = get_user_workout_number(request["user_id"], new_id)
|
user_number = get_user_workout_number(request["user_id"], new_id)
|
||||||
|
log_event(request["user_id"], "workout.update", {
|
||||||
|
"old_workout_id": workout_id,
|
||||||
|
"workout_id": new_id,
|
||||||
|
"user_number": user_number,
|
||||||
|
})
|
||||||
return web.json_response({"workout_id": new_id, "user_number": user_number})
|
return web.json_response({"workout_id": new_id, "user_number": user_number})
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -257,6 +267,10 @@ async def api_delete_workout(request: web.Request):
|
||||||
"""Soft-delete a workout by ID."""
|
"""Soft-delete a workout by ID."""
|
||||||
workout_id = int(request.match_info["workout_id"])
|
workout_id = int(request.match_info["workout_id"])
|
||||||
if delete_workout(request["user_id"], workout_id):
|
if delete_workout(request["user_id"], workout_id):
|
||||||
|
log_event(request["user_id"], "workout.delete", {
|
||||||
|
"source": "webapp",
|
||||||
|
"workout_id": workout_id,
|
||||||
|
})
|
||||||
return web.json_response({"deleted": True})
|
return web.json_response({"deleted": True})
|
||||||
return web.json_response({"error": "Not found"}, status=404)
|
return web.json_response({"error": "Not found"}, status=404)
|
||||||
|
|
||||||
|
|
@ -288,6 +302,21 @@ async def api_version(request: web.Request):
|
||||||
return web.json_response({"version": _VERSION})
|
return web.json_response({"version": _VERSION})
|
||||||
|
|
||||||
|
|
||||||
|
@require_auth
|
||||||
|
async def api_log_event(request: web.Request):
|
||||||
|
"""Record a client-emitted event (Mini App telemetry)."""
|
||||||
|
try:
|
||||||
|
body = await request.json()
|
||||||
|
except (ValueError, json.JSONDecodeError):
|
||||||
|
return web.json_response({"error": "Invalid JSON"}, status=400)
|
||||||
|
kind = body.get("kind")
|
||||||
|
if not isinstance(kind, str) or not kind:
|
||||||
|
return web.json_response({"error": "Missing kind"}, status=400)
|
||||||
|
data = body.get("data") if isinstance(body.get("data"), dict) else None
|
||||||
|
log_event(request["user_id"], kind, data)
|
||||||
|
return web.Response(status=204)
|
||||||
|
|
||||||
|
|
||||||
@require_auth
|
@require_auth
|
||||||
async def api_export_csv(request: web.Request):
|
async def api_export_csv(request: web.Request):
|
||||||
"""Export all workouts as CSV."""
|
"""Export all workouts as CSV."""
|
||||||
|
|
@ -322,6 +351,7 @@ def create_app() -> web.Application:
|
||||||
app.router.add_get("/api/export/json", api_export_json)
|
app.router.add_get("/api/export/json", api_export_json)
|
||||||
app.router.add_get("/api/export/csv", api_export_csv)
|
app.router.add_get("/api/export/csv", api_export_csv)
|
||||||
app.router.add_get("/api/version", api_version)
|
app.router.add_get("/api/version", api_version)
|
||||||
|
app.router.add_post("/api/events", api_log_event)
|
||||||
|
|
||||||
# Serve the webapp/ folder
|
# Serve the webapp/ folder
|
||||||
webapp_dir = pathlib.Path(__file__).parent / "webapp"
|
webapp_dir = pathlib.Path(__file__).parent / "webapp"
|
||||||
|
|
|
||||||
|
|
@ -253,6 +253,62 @@ class TestAllExerciseNames:
|
||||||
assert db.get_all_exercise_names() == ["Apple", "Mango", "Zebra"]
|
assert db.get_all_exercise_names() == ["Apple", "Mango", "Zebra"]
|
||||||
|
|
||||||
|
|
||||||
|
# ── events / log_event ───────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class TestEvents:
|
||||||
|
def test_log_and_fetch(self, tmp_db):
|
||||||
|
db.log_event(1, "cmd.start")
|
||||||
|
events = db.get_events()
|
||||||
|
assert len(events) == 1
|
||||||
|
assert events[0]["user_id"] == 1
|
||||||
|
assert events[0]["kind"] == "cmd.start"
|
||||||
|
assert events[0]["data"] is None
|
||||||
|
|
||||||
|
def test_log_with_data(self, tmp_db):
|
||||||
|
db.log_event(1, "set.add", {"exercise": "Bench", "reps": 8, "weight_kg": 35.0})
|
||||||
|
events = db.get_events()
|
||||||
|
assert events[0]["data"] == {"exercise": "Bench", "reps": 8, "weight_kg": 35.0}
|
||||||
|
|
||||||
|
def test_filter_by_user(self, tmp_db):
|
||||||
|
db.log_event(1, "cmd.start")
|
||||||
|
db.log_event(2, "cmd.start")
|
||||||
|
db.log_event(1, "cmd.history")
|
||||||
|
assert {e["kind"] for e in db.get_events(user_id=1)} == {"cmd.start", "cmd.history"}
|
||||||
|
assert {e["kind"] for e in db.get_events(user_id=2)} == {"cmd.start"}
|
||||||
|
|
||||||
|
def test_filter_by_kind(self, tmp_db):
|
||||||
|
db.log_event(1, "cmd.start")
|
||||||
|
db.log_event(1, "set.add", {"reps": 5})
|
||||||
|
db.log_event(2, "set.add", {"reps": 3})
|
||||||
|
sets = db.get_events(kind="set.add")
|
||||||
|
assert len(sets) == 2
|
||||||
|
assert all(e["kind"] == "set.add" for e in sets)
|
||||||
|
|
||||||
|
def test_newest_first(self, tmp_db):
|
||||||
|
db.log_event(1, "first")
|
||||||
|
db.log_event(1, "second")
|
||||||
|
db.log_event(1, "third")
|
||||||
|
kinds = [e["kind"] for e in db.get_events()]
|
||||||
|
assert kinds == ["third", "second", "first"]
|
||||||
|
|
||||||
|
def test_limit(self, tmp_db):
|
||||||
|
for i in range(5):
|
||||||
|
db.log_event(1, f"k{i}")
|
||||||
|
assert len(db.get_events(limit=2)) == 2
|
||||||
|
|
||||||
|
def test_null_user_allowed(self, tmp_db):
|
||||||
|
db.log_event(None, "system.tick")
|
||||||
|
events = db.get_events()
|
||||||
|
assert events[0]["user_id"] is None
|
||||||
|
|
||||||
|
def test_log_failure_returns_minus_one(self, tmp_db):
|
||||||
|
# Simulate failure by passing unserializable data
|
||||||
|
class X: pass
|
||||||
|
result = db.log_event(1, "bad", {"obj": X()})
|
||||||
|
assert result == -1
|
||||||
|
|
||||||
|
|
||||||
# ── update_workout ───────────────────────────────────────────────
|
# ── update_workout ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,24 @@ async function api(method, path, body = null) {
|
||||||
return res.json();
|
return res.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Event logging (fire-and-forget) ─────────────────────────────
|
||||||
|
function logEvent(kind, data) {
|
||||||
|
if (!userId) return;
|
||||||
|
try {
|
||||||
|
fetch(API + "/events", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"X-Telegram-Init-Data": tg.initData,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ kind, data: data || null }),
|
||||||
|
keepalive: true,
|
||||||
|
}).catch(() => {});
|
||||||
|
} catch (e) {
|
||||||
|
// Never let logging break anything
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ── Toast ───────────────────────────────────────────────────────
|
// ── Toast ───────────────────────────────────────────────────────
|
||||||
function showToast(msg) {
|
function showToast(msg) {
|
||||||
let toast = document.querySelector(".toast");
|
let toast = document.querySelector(".toast");
|
||||||
|
|
@ -330,6 +348,12 @@ function addSet() {
|
||||||
addSetToDOM(reps, weight);
|
addSetToDOM(reps, weight);
|
||||||
syncEditorUI();
|
syncEditorUI();
|
||||||
|
|
||||||
|
logEvent("set.add", {
|
||||||
|
exercise: currentExercise?.name || null,
|
||||||
|
reps,
|
||||||
|
weight_kg: weight,
|
||||||
|
});
|
||||||
|
|
||||||
repsInput.value = "";
|
repsInput.value = "";
|
||||||
weightInput.value = weight ? String(weight) : "";
|
weightInput.value = weight ? String(weight) : "";
|
||||||
repsInput.focus();
|
repsInput.focus();
|
||||||
|
|
@ -784,6 +808,7 @@ async function loadVersion() {
|
||||||
async function init() {
|
async function init() {
|
||||||
loadVersion();
|
loadVersion();
|
||||||
if (!userId) return;
|
if (!userId) return;
|
||||||
|
logEvent("miniapp.open");
|
||||||
try {
|
try {
|
||||||
const data = await api("GET", "/exercises");
|
const data = await api("GET", "/exercises");
|
||||||
knownExercises = data.exercises || [];
|
knownExercises = data.exercises || [];
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue