feat: implement new storage design per issue #2

- Storage: Change from per-user to per-group JSON files
- Data location: ~/.jigaido/ instead of apps/telegram-bot/data/
- Group bounties: data/{group_id}/group.json
- User tracking: data/{group_id}/{user_id}.json
- Personal bounties: data/{user_id}/user.json
- Update commands.py for new storage model
- Update bot.py to remove admin handlers
- Update tests to reflect created_by_user_id field
- Update SPEC.md with new design

Addresses user feedback from issue #2
This commit is contained in:
shokollm
2026-04-02 14:56:42 +00:00
parent 2e7b20ed81
commit 7c2bd09ada
5 changed files with 362 additions and 249 deletions

View File

@@ -1,49 +1,216 @@
"""Per-user JSON file storage for JIGAIDO."""
"""Per-group JSON file storage for JIGAIDO."""
import json
import tempfile
import os
import tempfile
from pathlib import Path
from typing import Optional
DATA_DIR = Path(__file__).parent / "data"
USERS_DIR = DATA_DIR / "users"
DATA_DIR = Path.home() / ".jigaido"
def _ensure_dirs() -> None:
USERS_DIR.mkdir(parents=True, exist_ok=True)
DATA_DIR.mkdir(parents=True, exist_ok=True)
def _user_file_path(telegram_user_id: int) -> Path:
return USERS_DIR / f"{telegram_user_id}.json"
def _group_dir(group_id: int) -> Path:
return DATA_DIR / str(group_id)
def load_user(telegram_user_id: int) -> dict:
"""Load user data from JSON file. Returns empty user structure if not found."""
_ensure_dirs()
path = _user_file_path(telegram_user_id)
if not path.exists():
return {
"user_id": telegram_user_id,
"username": None,
"personal_bounties": [],
"tracked_bounties": [],
}
with open(path) as f:
return json.load(f)
def _user_personal_dir(user_id: int) -> Path:
return DATA_DIR / str(user_id)
def save_user(user_data: dict) -> None:
"""Atomically save user data to JSON file."""
_ensure_dirs()
path = _user_file_path(user_data["user_id"])
with tempfile.NamedTemporaryFile(mode="w", dir=USERS_DIR, delete=False) as tmp:
json.dump(user_data, tmp, indent=2)
def _group_file(group_id: int) -> Path:
return _group_dir(group_id) / "group.json"
def _user_tracking_file(group_id: int, user_id: int) -> Path:
return _group_dir(group_id) / f"{user_id}.json"
def _user_personal_file(user_id: int) -> Path:
return _user_personal_dir(user_id) / "user.json"
def _atomic_write(path: Path, data: dict) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
with tempfile.NamedTemporaryFile(mode="w", dir=path.parent, delete=False) as tmp:
json.dump(data, tmp, indent=2)
tmp_path = tmp.name
os.rename(tmp_path, path)
def next_bounty_id(user_data: dict) -> int:
"""Get next sequential bounty ID for user's file."""
existing_ids = [b["id"] for b in user_data.get("personal_bounties", [])]
return (max(existing_ids) + 1) if existing_ids else 1
def load_group_bounties(group_id: int) -> dict:
_ensure_dirs()
path = _group_file(group_id)
if not path.exists():
return {"bounties": [], "next_id": 1}
with open(path) as f:
return json.load(f)
def save_group_bounties(group_id: int, data: dict) -> None:
_atomic_write(_group_file(group_id), data)
def add_group_bounty(
group_id: int,
created_by_user_id: int,
text: Optional[str],
link: Optional[str],
due_date_ts: Optional[int],
) -> dict:
data = load_group_bounties(group_id)
bounty = {
"id": data["next_id"],
"created_by_user_id": created_by_user_id,
"text": text,
"link": link,
"due_date_ts": due_date_ts,
"created_at": int(os.path.getctime(_group_file(group_id)))
if _group_file(group_id).exists()
else 0,
}
data["bounties"].append(bounty)
data["next_id"] += 1
save_group_bounties(group_id, data)
return bounty
def update_group_bounty(
group_id: int,
bounty_id: int,
text: Optional[str],
link: Optional[str],
due_date_ts: Optional[int],
) -> bool:
data = load_group_bounties(group_id)
for bounty in data["bounties"]:
if bounty["id"] == bounty_id:
if text is not None:
bounty["text"] = text
if link is not None:
bounty["link"] = link
if due_date_ts is not None:
bounty["due_date_ts"] = due_date_ts
save_group_bounties(group_id, data)
return True
return False
def delete_group_bounty(group_id: int, bounty_id: int) -> bool:
data = load_group_bounties(group_id)
for i, bounty in enumerate(data["bounties"]):
if bounty["id"] == bounty_id:
data["bounties"].pop(i)
save_group_bounties(group_id, data)
return True
return False
def get_group_bounty(group_id: int, bounty_id: int) -> Optional[dict]:
data = load_group_bounties(group_id)
for bounty in data["bounties"]:
if bounty["id"] == bounty_id:
return bounty
return None
def load_user_tracking(group_id: int, user_id: int) -> dict:
path = _user_tracking_file(group_id, user_id)
if not path.exists():
return {"tracked": []}
with open(path) as f:
return json.load(f)
def save_user_tracking(group_id: int, user_id: int, data: dict) -> None:
_atomic_write(_user_tracking_file(group_id, user_id), data)
def track_bounty(group_id: int, user_id: int, bounty_id: int) -> bool:
data = load_user_tracking(group_id, user_id)
if any(t["bounty_id"] == bounty_id for t in data["tracked"]):
return False
data["tracked"].append({"bounty_id": bounty_id})
save_user_tracking(group_id, user_id, data)
return True
def untrack_bounty(group_id: int, user_id: int, bounty_id: int) -> bool:
data = load_user_tracking(group_id, user_id)
for i, t in enumerate(data["tracked"]):
if t["bounty_id"] == bounty_id:
data["tracked"].pop(i)
save_user_tracking(group_id, user_id, data)
return True
return False
def load_user_personal(user_id: int) -> dict:
path = _user_personal_file(user_id)
if not path.exists():
return {"bounties": [], "next_id": 1}
with open(path) as f:
return json.load(f)
def save_user_personal(user_id: int, data: dict) -> None:
_atomic_write(_user_personal_file(user_id), data)
def add_personal_bounty(
user_id: int, text: Optional[str], link: Optional[str], due_date_ts: Optional[int]
) -> dict:
data = load_user_personal(user_id)
bounty = {
"id": data["next_id"],
"text": text,
"link": link,
"due_date_ts": due_date_ts,
"created_at": 0,
}
data["bounties"].append(bounty)
data["next_id"] += 1
save_user_personal(user_id, data)
return bounty
def update_personal_bounty(
user_id: int,
bounty_id: int,
text: Optional[str],
link: Optional[str],
due_date_ts: Optional[int],
) -> bool:
data = load_user_personal(user_id)
for bounty in data["bounties"]:
if bounty["id"] == bounty_id:
if text is not None:
bounty["text"] = text
if link is not None:
bounty["link"] = link
if due_date_ts is not None:
bounty["due_date_ts"] = due_date_ts
save_user_personal(user_id, data)
return True
return False
def delete_personal_bounty(user_id: int, bounty_id: int) -> bool:
data = load_user_personal(user_id)
for i, bounty in enumerate(data["bounties"]):
if bounty["id"] == bounty_id:
data["bounties"].pop(i)
save_user_personal(user_id, data)
return True
return False
def get_personal_bounty(user_id: int, bounty_id: int) -> Optional[dict]:
data = load_user_personal(user_id)
for bounty in data["bounties"]:
if bounty["id"] == bounty_id:
return bounty
return None