diff --git a/polisplexity/settings.py b/polisplexity/settings.py index d1a23d1..c4cda2b 100644 --- a/polisplexity/settings.py +++ b/polisplexity/settings.py @@ -65,6 +65,7 @@ INSTALLED_APPS = [ 'pxy_routing', 'pxy_sites', "pxy_simulations", + "pxy_stewards", "rest_framework", "pxy_api", diff --git a/polisplexity/urls.py b/polisplexity/urls.py index e2ad43b..29a642e 100644 --- a/polisplexity/urls.py +++ b/polisplexity/urls.py @@ -47,6 +47,7 @@ urlpatterns = [ path("api/", include("pxy_bots.api.urls")), path("api/langchain/", include("pxy_langchain.api.urls")), + path("api/", include("pxy_stewards.api.urls")), path("", include("pxy_openai.urls")), diff --git a/pxy_stewards/__init__.py b/pxy_stewards/__init__.py new file mode 100644 index 0000000..880828d --- /dev/null +++ b/pxy_stewards/__init__.py @@ -0,0 +1 @@ +"""Stewardship app for NFC + bot-driven cell powers.""" diff --git a/pxy_stewards/admin.py b/pxy_stewards/admin.py new file mode 100644 index 0000000..f1ba37b --- /dev/null +++ b/pxy_stewards/admin.py @@ -0,0 +1,24 @@ +from django.contrib import admin + +from .models import Stewardship, StewardBlessing, StewardSignal + + +@admin.register(Stewardship) +class StewardshipAdmin(admin.ModelAdmin): + list_display = ("platform", "user_id", "status", "geohash", "target_bot", "expires_at", "updated_at") + list_filter = ("platform", "status", "target_bot") + search_fields = ("user_id", "geohash", "target_bot") + + +@admin.register(StewardBlessing) +class StewardBlessingAdmin(admin.ModelAdmin): + list_display = ("geohash", "target_bot", "multiplier", "starts_at", "ends_at") + list_filter = ("target_bot",) + search_fields = ("geohash",) + + +@admin.register(StewardSignal) +class StewardSignalAdmin(admin.ModelAdmin): + list_display = ("geohash", "target_bot", "kind", "created_at") + list_filter = ("target_bot", "kind") + search_fields = ("geohash", "note") diff --git a/pxy_stewards/api/__init__.py b/pxy_stewards/api/__init__.py new file mode 100644 index 0000000..ec25d9a --- /dev/null +++ b/pxy_stewards/api/__init__.py @@ -0,0 +1 @@ +"""API for steward workflows.""" diff --git a/pxy_stewards/api/urls.py b/pxy_stewards/api/urls.py new file mode 100644 index 0000000..f901019 --- /dev/null +++ b/pxy_stewards/api/urls.py @@ -0,0 +1,9 @@ +from django.urls import path + +from .views import steward_handle, blessings_active, steward_health + +urlpatterns = [ + path("stewards/handle", steward_handle, name="stewards_handle"), + path("stewards/blessings", blessings_active, name="stewards_blessings"), + path("stewards/health", steward_health, name="stewards_health"), +] diff --git a/pxy_stewards/api/views.py b/pxy_stewards/api/views.py new file mode 100644 index 0000000..8d00121 --- /dev/null +++ b/pxy_stewards/api/views.py @@ -0,0 +1,328 @@ +import json +import os +from datetime import timedelta +from typing import Any, Dict, Optional + +from django.http import JsonResponse, HttpRequest +from django.utils import timezone +from django.views.decorators.csrf import csrf_exempt +from django.views.decorators.http import require_GET, require_http_methods + +from pxy_stewards.models import Stewardship, StewardBlessing, StewardSignal +from pxy_stewards.utils import encode_geohash + + +STEWARD_TTL_HOURS = int(os.getenv("STEWARD_TTL_HOURS", "168")) +STEWARD_SESSION_TTL_MIN = int(os.getenv("STEWARD_SESSION_TTL_MIN", "30")) +STEWARD_GEOHASH_PRECISION = int(os.getenv("STEWARD_GEOHASH_PRECISION", "7")) +STEWARD_ENERGY_MAX = int(os.getenv("STEWARD_ENERGY_MAX", "100")) +STEWARD_BLESS_COST = int(os.getenv("STEWARD_BLESS_COST", "25")) +STEWARD_BLESS_MINUTES = int(os.getenv("STEWARD_BLESS_MINUTES", "60")) +STEWARD_BLESS_MULTIPLIER = float(os.getenv("STEWARD_BLESS_MULTIPLIER", "2.0")) + +TARGET_BOTS = { + "coins": {"label": "Citizens (Pepe Basurita)", "bot": "PepeBasuritaCoinsBot"}, + "motito": {"label": "Motito (On-demand)", "bot": "PepeMotitoBot"}, + "camion": {"label": "Camioncito (Route)", "bot": "PepeCamioncitoBot"}, +} + + +def _render_text(text: str, buttons: Optional[list] = None) -> JsonResponse: + spec = { + "schema_version": "render.v1", + "messages": [{"type": "text", "text": text}], + } + if buttons: + spec["buttons"] = buttons + return JsonResponse(spec) + + +def _load_body(request: HttpRequest) -> Dict[str, Any]: + try: + raw = (request.body or b"").decode("utf-8") + return json.loads(raw or "{}") + except Exception: + return {} + + +def _extract_args(body: Dict[str, Any]) -> str: + args_raw = (body.get("input") or {}).get("args_raw") or "" + if args_raw.startswith("/"): + parts = args_raw.split(" ", 1) + return parts[1].strip() if len(parts) > 1 else "" + return args_raw.strip() + + +def _parse_latlon(raw: str) -> Optional[tuple[float, float]]: + parts = [p.strip() for p in (raw or "").split(",")] + if len(parts) != 2: + return None + try: + lat = float(parts[0]) + lon = float(parts[1]) + except Exception: + return None + return lat, lon + + +def _platform(body: Dict[str, Any]) -> str: + ctx = body.get("context") or {} + return (ctx.get("platform") or "telegram").strip().lower() + + +def _user_id(body: Dict[str, Any]) -> Optional[str]: + user = body.get("user") or {} + if user.get("id") is None: + return None + return str(user.get("id")) + + +def _bot_name(body: Dict[str, Any]) -> str: + bot = body.get("bot") or {} + return (bot.get("username") or "unknown").strip() + + +def _get_steward(platform: str, user_id: str) -> Stewardship: + steward, _ = Stewardship.objects.get_or_create( + platform=platform, + user_id=user_id, + defaults={"bot_name": "unknown", "energy": 0}, + ) + return steward + + +def _expire_if_needed(steward: Stewardship) -> None: + if steward.status == Stewardship.STATUS_ACTIVE and steward.expires_at: + if steward.expires_at <= timezone.now(): + steward.status = Stewardship.STATUS_EXPIRED + steward.energy = 0 + steward.save(update_fields=["status", "energy", "updated_at"]) + + +def _cell_taken(geohash: str, exclude_user_id: Optional[str] = None) -> bool: + now = timezone.now() + qs = Stewardship.objects.filter( + geohash=geohash, + status=Stewardship.STATUS_ACTIVE, + expires_at__gt=now, + ) + if exclude_user_id: + qs = qs.exclude(user_id=exclude_user_id) + return qs.exists() + + +def _activation_buttons() -> list: + buttons = [] + for key, meta in TARGET_BOTS.items(): + buttons.append({ + "label": meta["label"], + "kind": "callback_api", + "action": "choose", + "params": {"b": key}, + }) + return buttons + + +@csrf_exempt +@require_http_methods(["POST"]) +def steward_handle(request: HttpRequest): + body = _load_body(request) + platform = _platform(body) + user_id = _user_id(body) + bot_name = _bot_name(body) + if not user_id: + return _render_text("Missing user context. Try again from the bot.") + + cmd = ((body.get("command") or {}).get("name") or "").strip().lstrip("/").lower() + trigger = ((body.get("command") or {}).get("trigger") or "").strip().lower() + args = _extract_args(body) + location = (body.get("input") or {}).get("location") or {} + + steward = _get_steward(platform, user_id) + _expire_if_needed(steward) + if steward.bot_name != bot_name: + steward.bot_name = bot_name + steward.save(update_fields=["bot_name", "updated_at"]) + + if trigger == "callback": + data_raw = ((body.get("callback") or {}).get("data") or "") + try: + data = json.loads(data_raw) + except Exception: + data = {} + action = (data.get("a") or data.get("action") or "").lower() + params = data.get("p") or data.get("params") or {} + if action == "choose": + key = (params.get("b") or params.get("bot") or "").lower() + meta = TARGET_BOTS.get(key) + if not meta: + return _render_text("Unknown target bot. Try /steward again.") + if steward.status != Stewardship.STATUS_AWAITING_TARGET: + return _render_text("No pending activation. Use /steward to start.") + if steward.geohash and _cell_taken(steward.geohash, exclude_user_id=steward.user_id): + return _render_text("That cell already has a Steward. Choose a new location.") + steward.target_bot = meta["bot"] + steward.status = Stewardship.STATUS_ACTIVE + steward.expires_at = timezone.now() + timedelta(hours=STEWARD_TTL_HOURS) + steward.energy = STEWARD_ENERGY_MAX + steward.pending_until = None + steward.save() + text = ( + f"Steward activated for cell {steward.geohash}.\n" + f"Target bot: {meta['label']}.\n" + f"Power window: {STEWARD_TTL_HOURS}h.\n" + "Commands: /status /bless /signal" + ) + return _render_text(text) + return _render_text("Unknown action. Use /steward to start.") + + if cmd in {"steward", "start"}: + if steward.status == Stewardship.STATUS_ACTIVE and steward.expires_at and steward.expires_at > timezone.now(): + return _render_text( + f"You are already Steward of {steward.geohash}.\n" + "Use /status, /bless, or /signal." + ) + + latlon = _parse_latlon(args) + if latlon: + location = {"lat": latlon[0], "lon": latlon[1]} + + steward.status = Stewardship.STATUS_AWAITING_LOCATION + steward.pending_until = timezone.now() + timedelta(minutes=STEWARD_SESSION_TTL_MIN) + steward.save() + + if location.get("lat") is not None and location.get("lon") is not None: + cmd = "__location__" + else: + return _render_text( + "Steward activation started.\n" + "Send your location to anchor your cell.\n" + "Your identity stays private." + ) + + if cmd == "__location__": + if steward.status != Stewardship.STATUS_AWAITING_LOCATION: + return _render_text("Send /steward to start steward activation.") + if location.get("lat") is None or location.get("lon") is None: + return _render_text("Location missing. Send your location to continue.") + lat = float(location.get("lat")) + lon = float(location.get("lon")) + geohash = encode_geohash(lat, lon, precision=STEWARD_GEOHASH_PRECISION) + if _cell_taken(geohash, exclude_user_id=steward.user_id): + return _render_text( + "That cell already has a Steward.\n" + "Send another location to try a different cell." + ) + steward.lat = lat + steward.lon = lon + steward.geohash = geohash + steward.status = Stewardship.STATUS_AWAITING_TARGET + steward.pending_until = timezone.now() + timedelta(minutes=STEWARD_SESSION_TTL_MIN) + steward.save() + text = ( + f"Cell captured: {geohash}.\n" + "Choose which bot to empower for this cell:" + ) + return _render_text(text, buttons=_activation_buttons()) + + if cmd == "status": + if steward.status != Stewardship.STATUS_ACTIVE or not steward.geohash: + return _render_text("No active stewardship. Use /steward to begin.") + active_bless = StewardBlessing.objects.filter( + geohash=steward.geohash, + ends_at__gt=timezone.now(), + ).count() + remaining = int((steward.expires_at - timezone.now()).total_seconds() / 3600) if steward.expires_at else 0 + text = ( + f"Steward status\n" + f"Cell: {steward.geohash}\n" + f"Target bot: {steward.target_bot}\n" + f"Energy: {steward.energy}/{STEWARD_ENERGY_MAX}\n" + f"Blessings active: {active_bless}\n" + f"Power window: {remaining}h" + ) + return _render_text(text) + + if cmd == "bless": + if steward.status != Stewardship.STATUS_ACTIVE or not steward.geohash: + return _render_text("No active stewardship. Use /steward to begin.") + if steward.energy < STEWARD_BLESS_COST: + return _render_text("Not enough energy to bless. Try later.") + minutes = STEWARD_BLESS_MINUTES + if args: + try: + minutes = max(10, min(240, int(args.split()[0]))) + except Exception: + minutes = STEWARD_BLESS_MINUTES + starts = timezone.now() + ends = starts + timedelta(minutes=minutes) + StewardBlessing.objects.create( + steward=steward, + geohash=steward.geohash, + target_bot=steward.target_bot or "unknown", + multiplier=STEWARD_BLESS_MULTIPLIER, + starts_at=starts, + ends_at=ends, + ) + steward.energy = max(0, steward.energy - STEWARD_BLESS_COST) + steward.save(update_fields=["energy", "updated_at"]) + text = ( + f"Blessing active for {minutes} min.\n" + f"Cell: {steward.geohash}\n" + f"Boost: x{STEWARD_BLESS_MULTIPLIER:.1f} Green Points" + ) + return _render_text(text) + + if cmd == "signal": + if steward.status != Stewardship.STATUS_ACTIVE or not steward.geohash: + return _render_text("No active stewardship. Use /steward to begin.") + note = args or "cell_needs_attention" + StewardSignal.objects.create( + steward=steward, + geohash=steward.geohash, + target_bot=steward.target_bot or "unknown", + kind="signal", + note=note, + ) + return _render_text( + f"Signal recorded for {steward.geohash}.\n" + "The city agents will adjust in your cell." + ) + + return _render_text( + "Steward commands:\n" + "/steward - activate stewardship\n" + "/status - view status\n" + "/bless [minutes] - boost Green Points\n" + "/signal - log a cell signal" + ) + + +@csrf_exempt +@require_GET +def blessings_active(request: HttpRequest): + geohash = (request.GET.get("geohash") or "").strip() + target_bot = (request.GET.get("bot") or "").strip() + if not geohash: + return JsonResponse({"ok": False, "code": "missing_geohash"}, status=400) + now = timezone.now() + qs = StewardBlessing.objects.filter(geohash=geohash, ends_at__gt=now) + if target_bot: + qs = qs.filter(target_bot=target_bot) + data = [ + { + "geohash": b.geohash, + "target_bot": b.target_bot, + "multiplier": b.multiplier, + "starts_at": b.starts_at.isoformat(), + "ends_at": b.ends_at.isoformat(), + } + for b in qs + ] + return JsonResponse({"ok": True, "blessings": data}) + + +@csrf_exempt +@require_GET +def steward_health(request: HttpRequest): + return JsonResponse({"ok": True, "service": "pxy_stewards"}) diff --git a/pxy_stewards/apps.py b/pxy_stewards/apps.py new file mode 100644 index 0000000..844a7d3 --- /dev/null +++ b/pxy_stewards/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class PxyStewardsConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "pxy_stewards" diff --git a/pxy_stewards/migrations/0001_initial.py b/pxy_stewards/migrations/0001_initial.py new file mode 100644 index 0000000..adaa243 --- /dev/null +++ b/pxy_stewards/migrations/0001_initial.py @@ -0,0 +1,75 @@ +from django.db import migrations, models +import django.utils.timezone + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [] + + operations = [ + migrations.CreateModel( + name="Stewardship", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("platform", models.CharField(default="telegram", max_length=20)), + ("user_id", models.CharField(max_length=64)), + ("bot_name", models.CharField(max_length=80)), + ("status", models.CharField(choices=[("awaiting_location", "Awaiting location"), ("awaiting_target", "Awaiting target bot"), ("active", "Active"), ("expired", "Expired")], default="awaiting_location", max_length=32)), + ("geohash", models.CharField(blank=True, max_length=12, null=True)), + ("lat", models.FloatField(blank=True, null=True)), + ("lon", models.FloatField(blank=True, null=True)), + ("target_bot", models.CharField(blank=True, max_length=80, null=True)), + ("energy", models.PositiveIntegerField(default=0)), + ("expires_at", models.DateTimeField(blank=True, null=True)), + ("pending_until", models.DateTimeField(blank=True, null=True)), + ("created_at", models.DateTimeField(default=django.utils.timezone.now, editable=False)), + ("updated_at", models.DateTimeField(auto_now=True)), + ], + options={ + "indexes": [ + models.Index(fields=["platform", "user_id"], name="pxy_stewar_platform_2a65e9_idx"), + models.Index(fields=["geohash", "status", "expires_at"], name="pxy_stewar_geohash_626644_idx"), + ], + "unique_together": {("platform", "user_id")}, + }, + ), + migrations.CreateModel( + name="StewardBlessing", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("geohash", models.CharField(max_length=12)), + ("target_bot", models.CharField(max_length=80)), + ("multiplier", models.FloatField(default=2.0)), + ("starts_at", models.DateTimeField(default=django.utils.timezone.now)), + ("ends_at", models.DateTimeField()), + ("created_at", models.DateTimeField(default=django.utils.timezone.now, editable=False)), + ("steward", models.ForeignKey(on_delete=models.deletion.CASCADE, related_name="blessings", to="pxy_stewards.stewardship")), + ], + options={ + "indexes": [ + models.Index(fields=["geohash", "ends_at"], name="pxy_stewar_geohash_245c81_idx"), + models.Index(fields=["target_bot", "ends_at"], name="pxy_stewar_target__1c2e1f_idx"), + ], + }, + ), + migrations.CreateModel( + name="StewardSignal", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("geohash", models.CharField(max_length=12)), + ("target_bot", models.CharField(max_length=80)), + ("kind", models.CharField(default="signal", max_length=40)), + ("note", models.TextField(blank=True, default="")), + ("created_at", models.DateTimeField(default=django.utils.timezone.now, editable=False)), + ("steward", models.ForeignKey(on_delete=models.deletion.CASCADE, related_name="signals", to="pxy_stewards.stewardship")), + ], + options={ + "indexes": [ + models.Index(fields=["geohash", "created_at"], name="pxy_stewar_geohash_62497e_idx"), + models.Index(fields=["target_bot", "created_at"], name="pxy_stewar_target__73ec24_idx"), + ], + }, + ), + ] diff --git a/pxy_stewards/migrations/__init__.py b/pxy_stewards/migrations/__init__.py new file mode 100644 index 0000000..2f482c0 --- /dev/null +++ b/pxy_stewards/migrations/__init__.py @@ -0,0 +1 @@ +"""Migrations for pxy_stewards.""" diff --git a/pxy_stewards/models.py b/pxy_stewards/models.py new file mode 100644 index 0000000..eeab00f --- /dev/null +++ b/pxy_stewards/models.py @@ -0,0 +1,81 @@ +from django.db import models +from django.utils import timezone + + +class Stewardship(models.Model): + STATUS_AWAITING_LOCATION = "awaiting_location" + STATUS_AWAITING_TARGET = "awaiting_target" + STATUS_ACTIVE = "active" + STATUS_EXPIRED = "expired" + + STATUS_CHOICES = [ + (STATUS_AWAITING_LOCATION, "Awaiting location"), + (STATUS_AWAITING_TARGET, "Awaiting target bot"), + (STATUS_ACTIVE, "Active"), + (STATUS_EXPIRED, "Expired"), + ] + + platform = models.CharField(max_length=20, default="telegram") + user_id = models.CharField(max_length=64) + bot_name = models.CharField(max_length=80) + + status = models.CharField(max_length=32, choices=STATUS_CHOICES, default=STATUS_AWAITING_LOCATION) + geohash = models.CharField(max_length=12, blank=True, null=True) + lat = models.FloatField(null=True, blank=True) + lon = models.FloatField(null=True, blank=True) + target_bot = models.CharField(max_length=80, blank=True, null=True) + + energy = models.PositiveIntegerField(default=0) + expires_at = models.DateTimeField(null=True, blank=True) + pending_until = models.DateTimeField(null=True, blank=True) + + created_at = models.DateTimeField(default=timezone.now, editable=False) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + unique_together = ("platform", "user_id") + indexes = [ + models.Index(fields=["platform", "user_id"]), + models.Index(fields=["geohash", "status", "expires_at"]), + ] + + def __str__(self) -> str: + cell = self.geohash or "unset" + return f"{self.platform}:{self.user_id} @ {cell} ({self.status})" + + +class StewardBlessing(models.Model): + steward = models.ForeignKey(Stewardship, on_delete=models.CASCADE, related_name="blessings") + geohash = models.CharField(max_length=12) + target_bot = models.CharField(max_length=80) + multiplier = models.FloatField(default=2.0) + starts_at = models.DateTimeField(default=timezone.now) + ends_at = models.DateTimeField() + created_at = models.DateTimeField(default=timezone.now, editable=False) + + class Meta: + indexes = [ + models.Index(fields=["geohash", "ends_at"]), + models.Index(fields=["target_bot", "ends_at"]), + ] + + def __str__(self) -> str: + return f"Blessing {self.geohash} -> {self.target_bot}" + + +class StewardSignal(models.Model): + steward = models.ForeignKey(Stewardship, on_delete=models.CASCADE, related_name="signals") + geohash = models.CharField(max_length=12) + target_bot = models.CharField(max_length=80) + kind = models.CharField(max_length=40, default="signal") + note = models.TextField(blank=True, default="") + created_at = models.DateTimeField(default=timezone.now, editable=False) + + class Meta: + indexes = [ + models.Index(fields=["geohash", "created_at"]), + models.Index(fields=["target_bot", "created_at"]), + ] + + def __str__(self) -> str: + return f"Signal {self.geohash} -> {self.target_bot} ({self.kind})" diff --git a/pxy_stewards/utils.py b/pxy_stewards/utils.py new file mode 100644 index 0000000..9efd3f9 --- /dev/null +++ b/pxy_stewards/utils.py @@ -0,0 +1,35 @@ +_BASE32 = "0123456789bcdefghjkmnpqrstuvwxyz" +_BITS = [16, 8, 4, 2, 1] + + +def encode_geohash(lat: float, lon: float, precision: int = 7) -> str: + lat_interval = [-90.0, 90.0] + lon_interval = [-180.0, 180.0] + geohash = [] + bit = 0 + ch = 0 + even = True + + while len(geohash) < precision: + if even: + mid = (lon_interval[0] + lon_interval[1]) / 2 + if lon >= mid: + ch |= _BITS[bit] + lon_interval[0] = mid + else: + lon_interval[1] = mid + else: + mid = (lat_interval[0] + lat_interval[1]) / 2 + if lat >= mid: + ch |= _BITS[bit] + lat_interval[0] = mid + else: + lat_interval[1] = mid + even = not even + if bit < 4: + bit += 1 + else: + geohash.append(_BASE32[ch]) + bit = 0 + ch = 0 + return "".join(geohash)