File size: 5,383 Bytes
31c93b1
 
 
481b78e
31c93b1
 
a190f73
f08047d
 
31c93b1
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4aaae80
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
c91b229
4aaae80
 
31c93b1
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4aaae80
 
 
 
31c93b1
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4aaae80
 
 
 
31c93b1
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4aaae80
31c93b1
 
 
 
 
4aaae80
 
31c93b1
 
 
 
 
 
4aaae80
31c93b1
 
 
4aaae80
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
from __future__ import annotations

import uuid
from datetime import datetime, timedelta, timezone as _tz

from hearthnet.bus.capability import CapabilityDescriptor, RouteRequest

UTC = _tz.utc

from hearthnet.constants import MARKET_DEFAULT_TTL_SECONDS
from hearthnet.services.marketplace.views import MarketplaceView


class MarketplaceService:
    name = "marketplace"
    version = "1.0"

    def __init__(self, event_log=None, node_id: str = "") -> None:
        self._event_log = event_log  # optional X02 EventLog
        self._node_id = node_id
        self._view = MarketplaceView()
        self._sweep_task = None
        self._posts_demo: list[dict] = []

    @property
    def posts(self) -> list[dict]:
        """Backward-compatible access to demo-mode post list."""
        return self._posts_demo

    def capabilities(self) -> list[tuple]:
        return [
            (
                CapabilityDescriptor(name="market.post", max_concurrent=4, idempotent=True),
                self.handle_post,
                None,
            ),
            (
                CapabilityDescriptor(name="market.list", max_concurrent=8, idempotent=True),
                self.handle_list,
                None,
            ),
            (
                CapabilityDescriptor(name="market.expire", max_concurrent=4, idempotent=True),
                self.handle_expire,
                None,
            ),
            (
                CapabilityDescriptor(name="market.search", max_concurrent=4, idempotent=True),
                self.handle_search,
                None,
            ),
            (
                CapabilityDescriptor(name="market.delete", max_concurrent=4),
                self.handle_expire,  # delete = immediate expire
                None,
            ),
        ]

    async def handle_post(self, req: RouteRequest) -> dict:
        payload = dict(req.body.get("input", {}))
        event_id = payload.get("event_id") or f"evt:{uuid.uuid4().hex}"
        payload.setdefault("client_id", event_id)
        payload.setdefault("author", req.caller)
        payload.setdefault("created_at", _iso_now())
        payload.setdefault("expires_at", _iso_after(MARKET_DEFAULT_TTL_SECONDS))
        payload.setdefault("category", "info")

        if self._event_log is not None:
            try:
                event = self._event_log.append_local(
                    event_type="market.post.created",
                    author=req.caller,
                    payload=payload,
                )
                self._view.apply(event)
                return {
                    "output": {"event_id": event.event_id, "lamport": event.lamport},
                    "meta": {},
                }
            except Exception:
                pass  # fall through to demo mode

        # Demo mode (no event log)
        payload["event_id"] = event_id
        payload["lamport"] = len(self._posts_demo) + 1
        self._posts_demo.append(payload)
        return {"output": {"event_id": event_id, "lamport": len(self._posts_demo)}, "meta": {}}

    async def handle_list(self, req: RouteRequest) -> dict:
        category = req.body.get("input", {}).get("category")

        if self._event_log is not None:
            posts = self._view.all_active()
            result = [p.as_dict() for p in posts if not category or p.category == category]
        else:
            result = [p for p in self._posts_demo if not category or p.get("category") == category]

        return {"output": {"posts": result, "max_lamport": len(result)}, "meta": {}}

    async def handle_expire(self, req: RouteRequest) -> dict:
        inp = req.body.get("input", {})
        target_event_id = inp.get("event_id", "")

        if self._event_log is not None:
            try:
                event = self._event_log.append_local(
                    event_type="market.post.expired",
                    author=req.caller,
                    payload={
                        "target_event_id": target_event_id,
                        "reason": inp.get("reason", "manual"),
                    },
                )
                self._view.apply(event)
                return {"output": {"expired": True, "event_id": target_event_id}, "meta": {}}
            except Exception:
                pass

        # Demo mode
        self._posts_demo = [p for p in self._posts_demo if p.get("event_id") != target_event_id]
        return {"output": {"expired": True, "event_id": target_event_id}, "meta": {}}

    async def handle_search(self, req: RouteRequest) -> dict:
        query = req.body.get("input", {}).get("query", "").lower()

        if self._event_log is not None:
            posts = self._view.all_active()
            result = [
                p.as_dict() for p in posts if query in p.title.lower() or query in p.body.lower()
            ]
            return {"output": {"posts": result}, "meta": {}}

        # Demo mode
        result = [
            p
            for p in self._posts_demo
            if query in p.get("title", "").lower() or query in p.get("body", "").lower()
        ]
        return {"output": {"posts": result}, "meta": {}}


def _iso_now() -> str:
    return datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%SZ")


def _iso_after(seconds: int) -> str:
    return (datetime.now(UTC) + timedelta(seconds=seconds)).strftime("%Y-%m-%dT%H:%M:%SZ")