fetch-hackkit 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- fetch_hackkit/__init__.py +33 -0
- fetch_hackkit/agent.py +340 -0
- fetch_hackkit/asi1.py +294 -0
- fetch_hackkit/cards.py +526 -0
- fetch_hackkit/cli.py +90 -0
- fetch_hackkit/doctor.py +209 -0
- fetch_hackkit/mcp.py +93 -0
- fetch_hackkit/payments.py +264 -0
- fetch_hackkit/peers.py +40 -0
- fetch_hackkit/py.typed +0 -0
- fetch_hackkit/register.py +240 -0
- fetch_hackkit-0.1.0.dist-info/METADATA +72 -0
- fetch_hackkit-0.1.0.dist-info/RECORD +16 -0
- fetch_hackkit-0.1.0.dist-info/WHEEL +4 -0
- fetch_hackkit-0.1.0.dist-info/entry_points.txt +2 -0
- fetch_hackkit-0.1.0.dist-info/licenses/LICENSE +21 -0
fetch_hackkit/cards.py
ADDED
|
@@ -0,0 +1,526 @@
|
|
|
1
|
+
"""ASI:One Interactive Cards: builders, validation, and selection parsing.
|
|
2
|
+
|
|
3
|
+
A card is a normal chat-protocol ``ChatMessage`` with two content blocks:
|
|
4
|
+
a ``TextContent`` narration (shown in the chat bubble) and a ``MetadataContent``
|
|
5
|
+
declaring the card. When the user taps a button, ASI:One sends the button's
|
|
6
|
+
``selection`` back to your agent as text (JSON on a direct @mention, a prose
|
|
7
|
+
sentence when routed by the ASI:One planner). :func:`parse_selection` handles both.
|
|
8
|
+
|
|
9
|
+
Every builder here puts an ``action`` key in each selection, so
|
|
10
|
+
``HackAgent.on_action("name")`` can route taps without any parsing code.
|
|
11
|
+
|
|
12
|
+
Spec: https://innovationlab.fetch.ai/resources/docs/interactive-cards/asi-interactive-cards
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import copy
|
|
18
|
+
import json
|
|
19
|
+
import re
|
|
20
|
+
from dataclasses import dataclass
|
|
21
|
+
from datetime import UTC, datetime
|
|
22
|
+
from typing import Any, Literal
|
|
23
|
+
from uuid import uuid4
|
|
24
|
+
|
|
25
|
+
from uagents_core.contrib.protocols.chat import ChatMessage, MetadataContent, TextContent
|
|
26
|
+
|
|
27
|
+
CardKind = Literal["carousel", "detail", "form", "review", "custom"]
|
|
28
|
+
CARD_KINDS: tuple[str, ...] = ("carousel", "detail", "form", "review", "custom")
|
|
29
|
+
FieldKind = Literal["text", "number", "email", "select", "checkbox"]
|
|
30
|
+
BadgeVariant = Literal["info", "success", "warning"]
|
|
31
|
+
|
|
32
|
+
CARD_PROTOCOL_VERSION = "1"
|
|
33
|
+
# ASI:One rejects payloads over 64 KB; stay under it with headroom.
|
|
34
|
+
MAX_PAYLOAD_BYTES = 60_000
|
|
35
|
+
# Custom card trees may nest at most 8 levels (root = level 1).
|
|
36
|
+
MAX_CUSTOM_DEPTH = 8
|
|
37
|
+
|
|
38
|
+
Selection = dict[str, Any]
|
|
39
|
+
Options = list[dict[str, str]] | list[tuple[str, str]] | list[str]
|
|
40
|
+
Rows = dict[str, str] | list[tuple[str, str]] | list[dict[str, str]]
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class CardError(ValueError):
|
|
44
|
+
"""Raised when a card payload would be rejected by ASI:One."""
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass(frozen=True)
|
|
48
|
+
class Card:
|
|
49
|
+
"""A card ready to send. Return it from a handler, or call :meth:`to_message`."""
|
|
50
|
+
|
|
51
|
+
kind: CardKind
|
|
52
|
+
payload: dict[str, Any]
|
|
53
|
+
narration: str = ""
|
|
54
|
+
terminal: bool = False
|
|
55
|
+
drawer_width: int | None = None
|
|
56
|
+
|
|
57
|
+
def to_message(self, *, card_id: str | None = None) -> ChatMessage:
|
|
58
|
+
return card_message(
|
|
59
|
+
self.narration,
|
|
60
|
+
self.kind,
|
|
61
|
+
self.payload,
|
|
62
|
+
terminal=self.terminal,
|
|
63
|
+
drawer_width=self.drawer_width,
|
|
64
|
+
card_id=card_id,
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
# --------------------------------------------------------------------------- #
|
|
69
|
+
# Small building blocks shared by the card kinds
|
|
70
|
+
# --------------------------------------------------------------------------- #
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def selection(action: str, **data: Any) -> Selection:
|
|
74
|
+
"""What a button sends back when tapped: ``{"action": action, **data}``."""
|
|
75
|
+
return {"action": action, **data}
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def cta(label: str, action: str, *, primary: bool = False, **data: Any) -> dict[str, Any]:
|
|
79
|
+
"""A call-to-action button. ``data`` is echoed back with the tap."""
|
|
80
|
+
out: dict[str, Any] = {"label": label, "selection": selection(action, **data)}
|
|
81
|
+
if primary:
|
|
82
|
+
out["primary"] = True
|
|
83
|
+
return out
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def badge(label: str, variant: BadgeVariant = "info") -> dict[str, str]:
|
|
87
|
+
return {"label": label, "variant": variant}
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _options(options: Options) -> list[dict[str, str]]:
|
|
91
|
+
out: list[dict[str, str]] = []
|
|
92
|
+
for opt in options:
|
|
93
|
+
if isinstance(opt, dict):
|
|
94
|
+
out.append({"value": str(opt["value"]), "label": str(opt.get("label", opt["value"]))})
|
|
95
|
+
elif isinstance(opt, tuple):
|
|
96
|
+
out.append({"value": str(opt[0]), "label": str(opt[1])})
|
|
97
|
+
else:
|
|
98
|
+
out.append({"value": str(opt), "label": str(opt)})
|
|
99
|
+
return out
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _rows(rows: Rows) -> list[dict[str, str]]:
|
|
103
|
+
if isinstance(rows, dict):
|
|
104
|
+
return [{"label": str(k), "value": str(v)} for k, v in rows.items()]
|
|
105
|
+
out: list[dict[str, str]] = []
|
|
106
|
+
for row in rows:
|
|
107
|
+
if isinstance(row, dict):
|
|
108
|
+
out.append({"label": str(row["label"]), "value": str(row["value"])})
|
|
109
|
+
else:
|
|
110
|
+
out.append({"label": str(row[0]), "value": str(row[1])})
|
|
111
|
+
return out
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _drop_none(d: dict[str, Any]) -> dict[str, Any]:
|
|
115
|
+
return {k: v for k, v in d.items() if v is not None}
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
# --------------------------------------------------------------------------- #
|
|
119
|
+
# Card kinds
|
|
120
|
+
# --------------------------------------------------------------------------- #
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def item(
|
|
124
|
+
id: str,
|
|
125
|
+
title: str,
|
|
126
|
+
*,
|
|
127
|
+
action: str,
|
|
128
|
+
cta_label: str = "Select",
|
|
129
|
+
subtitle: str | None = None,
|
|
130
|
+
image: str | None = None,
|
|
131
|
+
badges: list[dict[str, str]] | None = None,
|
|
132
|
+
secondary_text: str | None = None,
|
|
133
|
+
**data: Any,
|
|
134
|
+
) -> dict[str, Any]:
|
|
135
|
+
"""One carousel item. The tap sends ``{"action": action, "id": id, **data}``."""
|
|
136
|
+
return _drop_none(
|
|
137
|
+
{
|
|
138
|
+
"id": id,
|
|
139
|
+
"title": title,
|
|
140
|
+
"subtitle": subtitle,
|
|
141
|
+
"image": image,
|
|
142
|
+
"badges": badges,
|
|
143
|
+
"secondary_text": secondary_text,
|
|
144
|
+
"primary_cta": cta(cta_label, action, id=id, **data),
|
|
145
|
+
}
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def carousel(
|
|
150
|
+
title: str,
|
|
151
|
+
items: list[dict[str, Any]],
|
|
152
|
+
*,
|
|
153
|
+
subtitle: str | None = None,
|
|
154
|
+
narration: str = "",
|
|
155
|
+
drawer_width: int | None = None,
|
|
156
|
+
) -> Card:
|
|
157
|
+
"""A horizontally scrollable list; each item has one primary button."""
|
|
158
|
+
payload = _drop_none({"title": title, "subtitle": subtitle, "items": items})
|
|
159
|
+
return Card("carousel", payload, narration, drawer_width=drawer_width)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def radio(name: str, label: str, choices: Options) -> dict[str, Any]:
|
|
163
|
+
"""Radio sub-options for a detail card. The chosen value is sent as ``name``."""
|
|
164
|
+
return {"name": name, "kind": "radio", "label": label, "choices": _options(choices)}
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def detail(
|
|
168
|
+
title: str,
|
|
169
|
+
*,
|
|
170
|
+
rows: Rows,
|
|
171
|
+
ctas: list[dict[str, Any]],
|
|
172
|
+
hero_image: str | None = None,
|
|
173
|
+
sub_options: dict[str, Any] | None = None,
|
|
174
|
+
narration: str = "",
|
|
175
|
+
drawer_width: int | None = None,
|
|
176
|
+
) -> Card:
|
|
177
|
+
"""A hero image, key/value rows, optional radio choice, and one or more buttons."""
|
|
178
|
+
payload = _drop_none(
|
|
179
|
+
{
|
|
180
|
+
"title": title,
|
|
181
|
+
"hero_image": hero_image,
|
|
182
|
+
"summary_rows": _rows(rows),
|
|
183
|
+
"sub_options": sub_options,
|
|
184
|
+
"ctas": ctas,
|
|
185
|
+
}
|
|
186
|
+
)
|
|
187
|
+
return Card("detail", payload, narration, drawer_width=drawer_width)
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def field(
|
|
191
|
+
name: str,
|
|
192
|
+
label: str,
|
|
193
|
+
kind: FieldKind = "text",
|
|
194
|
+
*,
|
|
195
|
+
required: bool = False,
|
|
196
|
+
options: Options | None = None,
|
|
197
|
+
placeholder: str | None = None,
|
|
198
|
+
) -> dict[str, Any]:
|
|
199
|
+
"""A form field. ``select`` fields need ``options``."""
|
|
200
|
+
out: dict[str, Any] = {"name": name, "kind": kind, "label": label}
|
|
201
|
+
if required:
|
|
202
|
+
out["required"] = True
|
|
203
|
+
if options is not None:
|
|
204
|
+
out["options"] = _options(options)
|
|
205
|
+
if placeholder is not None:
|
|
206
|
+
out["placeholder"] = placeholder
|
|
207
|
+
return out
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def form(
|
|
211
|
+
title: str,
|
|
212
|
+
fields: list[dict[str, Any]],
|
|
213
|
+
*,
|
|
214
|
+
submit: dict[str, Any],
|
|
215
|
+
narration: str = "",
|
|
216
|
+
drawer_width: int | None = None,
|
|
217
|
+
) -> Card:
|
|
218
|
+
"""Labeled inputs plus a submit button. All field values come back with the tap."""
|
|
219
|
+
payload = {"title": title, "fields": fields, "submit_cta": submit}
|
|
220
|
+
return Card("form", payload, narration, drawer_width=drawer_width)
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def review(
|
|
224
|
+
title: str,
|
|
225
|
+
*,
|
|
226
|
+
rows: Rows,
|
|
227
|
+
approve: dict[str, Any],
|
|
228
|
+
reject: dict[str, Any],
|
|
229
|
+
narration: str = "",
|
|
230
|
+
drawer_width: int | None = None,
|
|
231
|
+
) -> Card:
|
|
232
|
+
"""Summary rows plus approve/reject. Use before anything irreversible (payments, bookings)."""
|
|
233
|
+
payload = {
|
|
234
|
+
"title": title,
|
|
235
|
+
"summary_rows": _rows(rows),
|
|
236
|
+
"approve_cta": approve,
|
|
237
|
+
"reject_cta": reject,
|
|
238
|
+
}
|
|
239
|
+
return Card("review", payload, narration, drawer_width=drawer_width)
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def custom(root: dict[str, Any], *, narration: str = "", drawer_width: int | None = None) -> Card:
|
|
243
|
+
"""A free-form layout built from :class:`ui` primitives."""
|
|
244
|
+
return Card("custom", {"root": root}, narration, drawer_width=drawer_width)
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def info(title: str, *, rows: Rows, narration: str = "") -> Card:
|
|
248
|
+
"""A terminal (read-only) detail card that expects no input."""
|
|
249
|
+
payload = {"title": title, "summary_rows": _rows(rows), "ctas": []}
|
|
250
|
+
return Card("detail", payload, narration, terminal=True)
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
class ui:
|
|
254
|
+
"""Primitives for :func:`custom` cards, e.g. ``ui.section("Hi", [ui.text("...")])``."""
|
|
255
|
+
|
|
256
|
+
@staticmethod
|
|
257
|
+
def section(
|
|
258
|
+
title: str | None, children: list[dict[str, Any]], *, subtitle: str | None = None
|
|
259
|
+
) -> dict[str, Any]:
|
|
260
|
+
return _drop_none(
|
|
261
|
+
{"type": "section", "title": title, "subtitle": subtitle, "children": children}
|
|
262
|
+
)
|
|
263
|
+
|
|
264
|
+
@staticmethod
|
|
265
|
+
def group(
|
|
266
|
+
children: list[dict[str, Any]],
|
|
267
|
+
*,
|
|
268
|
+
direction: Literal["row", "column"] = "row",
|
|
269
|
+
gap: int | None = None,
|
|
270
|
+
) -> dict[str, Any]:
|
|
271
|
+
return _drop_none(
|
|
272
|
+
{"type": "group", "direction": direction, "gap": gap, "children": children}
|
|
273
|
+
)
|
|
274
|
+
|
|
275
|
+
@staticmethod
|
|
276
|
+
def divider() -> dict[str, Any]:
|
|
277
|
+
return {"type": "divider"}
|
|
278
|
+
|
|
279
|
+
@staticmethod
|
|
280
|
+
def text(
|
|
281
|
+
value: str, style: Literal["body", "muted", "emphasis"] | None = None
|
|
282
|
+
) -> dict[str, Any]:
|
|
283
|
+
return _drop_none({"type": "text", "value": value, "style": style})
|
|
284
|
+
|
|
285
|
+
@staticmethod
|
|
286
|
+
def heading(value: str, level: Literal[1, 2, 3] | None = None) -> dict[str, Any]:
|
|
287
|
+
return _drop_none({"type": "heading", "value": value, "level": level})
|
|
288
|
+
|
|
289
|
+
@staticmethod
|
|
290
|
+
def image(src: str, alt: str | None = None, aspect_ratio: str | None = None) -> dict[str, Any]:
|
|
291
|
+
return _drop_none({"type": "image", "src": src, "alt": alt, "aspect_ratio": aspect_ratio})
|
|
292
|
+
|
|
293
|
+
@staticmethod
|
|
294
|
+
def badge(label: str, variant: BadgeVariant | None = None) -> dict[str, Any]:
|
|
295
|
+
return _drop_none({"type": "badge", "label": label, "variant": variant})
|
|
296
|
+
|
|
297
|
+
@staticmethod
|
|
298
|
+
def button(label: str, action: str, *, primary: bool = False, **data: Any) -> dict[str, Any]:
|
|
299
|
+
out: dict[str, Any] = {
|
|
300
|
+
"type": "button",
|
|
301
|
+
"label": label,
|
|
302
|
+
"action": {"selection": selection(action, **data)},
|
|
303
|
+
}
|
|
304
|
+
if primary:
|
|
305
|
+
out["primary"] = True
|
|
306
|
+
return out
|
|
307
|
+
|
|
308
|
+
@staticmethod
|
|
309
|
+
def input(
|
|
310
|
+
name: str,
|
|
311
|
+
label: str,
|
|
312
|
+
kind: FieldKind = "text",
|
|
313
|
+
*,
|
|
314
|
+
required: bool = False,
|
|
315
|
+
options: Options | None = None,
|
|
316
|
+
placeholder: str | None = None,
|
|
317
|
+
) -> dict[str, Any]:
|
|
318
|
+
return {
|
|
319
|
+
"type": "input",
|
|
320
|
+
**field(name, label, kind, required=required, options=options, placeholder=placeholder),
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
@staticmethod
|
|
324
|
+
def list_items(items: list[dict[str, Any]]) -> dict[str, Any]:
|
|
325
|
+
"""``items``: dicts from :meth:`list_item`."""
|
|
326
|
+
return {"type": "list", "items": items}
|
|
327
|
+
|
|
328
|
+
@staticmethod
|
|
329
|
+
def list_item(
|
|
330
|
+
children: list[dict[str, Any]], action: str | None = None, **data: Any
|
|
331
|
+
) -> dict[str, Any]:
|
|
332
|
+
out: dict[str, Any] = {"children": children}
|
|
333
|
+
if action:
|
|
334
|
+
out["action"] = {"selection": selection(action, **data)}
|
|
335
|
+
return out
|
|
336
|
+
|
|
337
|
+
@staticmethod
|
|
338
|
+
def choice_grid(
|
|
339
|
+
name: str, choices: list[dict[str, str]], *, multi: bool = False
|
|
340
|
+
) -> dict[str, Any]:
|
|
341
|
+
"""``choices``: ``[{"value": ..., "label": ..., "image": optional}]``."""
|
|
342
|
+
out: dict[str, Any] = {"type": "choice_grid", "name": name, "choices": choices}
|
|
343
|
+
if multi:
|
|
344
|
+
out["multi"] = True
|
|
345
|
+
return out
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
# --------------------------------------------------------------------------- #
|
|
349
|
+
# Validation + message assembly
|
|
350
|
+
# --------------------------------------------------------------------------- #
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
def _depth(node: Any) -> int:
|
|
354
|
+
if isinstance(node, dict):
|
|
355
|
+
kids = node.get("children") or []
|
|
356
|
+
items = node.get("items") if node.get("type") == "list" else None
|
|
357
|
+
nested = list(kids) + [c for it in (items or []) for c in it.get("children", [])]
|
|
358
|
+
return 1 + max((_depth(c) for c in nested), default=0)
|
|
359
|
+
return 0
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
def _ctas_ok(ctas: Any) -> bool:
|
|
363
|
+
return isinstance(ctas, dict) and isinstance(ctas.get("selection"), dict) and "label" in ctas
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
def validate(kind: str, payload: dict[str, Any]) -> list[str]:
|
|
367
|
+
"""Return the problems ASI:One would reject this card for (empty list = valid)."""
|
|
368
|
+
errors: list[str] = []
|
|
369
|
+
if kind not in CARD_KINDS:
|
|
370
|
+
return [f"unknown card kind {kind!r}; use one of {', '.join(CARD_KINDS)}"]
|
|
371
|
+
try:
|
|
372
|
+
size = len(json.dumps(payload, ensure_ascii=False).encode("utf-8"))
|
|
373
|
+
except (TypeError, ValueError) as exc:
|
|
374
|
+
return [f"payload is not JSON-serialisable: {exc}"]
|
|
375
|
+
if size > MAX_PAYLOAD_BYTES:
|
|
376
|
+
errors.append(f"payload is {size} bytes; keep it under {MAX_PAYLOAD_BYTES}")
|
|
377
|
+
|
|
378
|
+
if kind == "carousel":
|
|
379
|
+
items = payload.get("items")
|
|
380
|
+
if not isinstance(items, list) or not items:
|
|
381
|
+
errors.append("carousel needs a non-empty 'items' list")
|
|
382
|
+
else:
|
|
383
|
+
for i, it in enumerate(items):
|
|
384
|
+
if not it.get("title"):
|
|
385
|
+
errors.append(f"items[{i}] needs a 'title'")
|
|
386
|
+
if not _ctas_ok(it.get("primary_cta")):
|
|
387
|
+
errors.append(f"items[{i}] needs a 'primary_cta' with label + selection")
|
|
388
|
+
elif kind == "detail":
|
|
389
|
+
if not isinstance(payload.get("ctas"), list):
|
|
390
|
+
errors.append("detail needs a 'ctas' list")
|
|
391
|
+
for i, c in enumerate(payload.get("ctas") or []):
|
|
392
|
+
if not _ctas_ok(c):
|
|
393
|
+
errors.append(f"ctas[{i}] needs label + selection")
|
|
394
|
+
elif kind == "form":
|
|
395
|
+
fields = payload.get("fields")
|
|
396
|
+
if not isinstance(fields, list) or not fields:
|
|
397
|
+
errors.append("form needs a non-empty 'fields' list")
|
|
398
|
+
for f in fields or []:
|
|
399
|
+
if f.get("kind") == "select" and not f.get("options"):
|
|
400
|
+
errors.append(f"select field {f.get('name')!r} needs non-empty 'options'")
|
|
401
|
+
if not _ctas_ok(payload.get("submit_cta")):
|
|
402
|
+
errors.append("form needs a 'submit_cta' with label + selection")
|
|
403
|
+
elif kind == "review":
|
|
404
|
+
for key in ("approve_cta", "reject_cta"):
|
|
405
|
+
if not _ctas_ok(payload.get(key)):
|
|
406
|
+
errors.append(f"review needs '{key}' with label + selection")
|
|
407
|
+
elif kind == "custom":
|
|
408
|
+
root = payload.get("root")
|
|
409
|
+
if not isinstance(root, dict) or "type" not in root:
|
|
410
|
+
errors.append("custom card needs a 'root' node with a 'type'")
|
|
411
|
+
elif (d := _depth(root)) > MAX_CUSTOM_DEPTH:
|
|
412
|
+
errors.append(f"custom tree is {d} levels deep; max is {MAX_CUSTOM_DEPTH}")
|
|
413
|
+
return errors
|
|
414
|
+
|
|
415
|
+
|
|
416
|
+
def _stamp_card_id(node: Any, card_id: str) -> None:
|
|
417
|
+
if isinstance(node, dict):
|
|
418
|
+
sel = node.get("selection")
|
|
419
|
+
if isinstance(sel, dict):
|
|
420
|
+
sel["card_id"] = card_id
|
|
421
|
+
for value in node.values():
|
|
422
|
+
_stamp_card_id(value, card_id)
|
|
423
|
+
elif isinstance(node, list):
|
|
424
|
+
for value in node:
|
|
425
|
+
_stamp_card_id(value, card_id)
|
|
426
|
+
|
|
427
|
+
|
|
428
|
+
def card_message(
|
|
429
|
+
narration: str,
|
|
430
|
+
kind: str,
|
|
431
|
+
payload: dict[str, Any],
|
|
432
|
+
*,
|
|
433
|
+
terminal: bool = False,
|
|
434
|
+
drawer_width: int | None = None,
|
|
435
|
+
card_id: str | None = None,
|
|
436
|
+
) -> ChatMessage:
|
|
437
|
+
"""Build the ``ChatMessage`` for a card. Raises :class:`CardError` if invalid.
|
|
438
|
+
|
|
439
|
+
``card_id`` is stamped into every selection so you can ignore taps on an
|
|
440
|
+
older card (``parse_selection(...)["card_id"]``).
|
|
441
|
+
"""
|
|
442
|
+
errors = validate(kind, payload)
|
|
443
|
+
if errors:
|
|
444
|
+
raise CardError(f"invalid {kind} card: " + "; ".join(errors))
|
|
445
|
+
if card_id:
|
|
446
|
+
payload = copy.deepcopy(payload)
|
|
447
|
+
_stamp_card_id(payload, card_id)
|
|
448
|
+
metadata = {
|
|
449
|
+
"card_protocol_version": CARD_PROTOCOL_VERSION,
|
|
450
|
+
"requires_card_interaction": "true",
|
|
451
|
+
"card_kind": kind,
|
|
452
|
+
"card_payload": json.dumps(payload, ensure_ascii=False),
|
|
453
|
+
}
|
|
454
|
+
if terminal:
|
|
455
|
+
metadata["is_terminal"] = "true"
|
|
456
|
+
if drawer_width:
|
|
457
|
+
metadata["preferred_drawer_width_px"] = str(max(320, min(800, int(drawer_width))))
|
|
458
|
+
if card_id:
|
|
459
|
+
metadata["card_id"] = card_id
|
|
460
|
+
content: list[Any] = []
|
|
461
|
+
if narration:
|
|
462
|
+
content.append(TextContent(type="text", text=narration))
|
|
463
|
+
content.append(MetadataContent(type="metadata", metadata=metadata))
|
|
464
|
+
return ChatMessage(timestamp=datetime.now(UTC), msg_id=uuid4(), content=content)
|
|
465
|
+
|
|
466
|
+
|
|
467
|
+
# --------------------------------------------------------------------------- #
|
|
468
|
+
# Reading taps back
|
|
469
|
+
# --------------------------------------------------------------------------- #
|
|
470
|
+
|
|
471
|
+
_MENTION_PREFIX = re.compile(r"^(?:@\S+\s+)+")
|
|
472
|
+
|
|
473
|
+
|
|
474
|
+
def strip_mention(text: str) -> str:
|
|
475
|
+
"""Remove leading ``@handle`` mentions (``"@my-agent hi"`` -> ``"hi"``)."""
|
|
476
|
+
return _MENTION_PREFIX.sub("", (text or "").strip()).strip()
|
|
477
|
+
|
|
478
|
+
|
|
479
|
+
def collect_actions(payload: dict[str, Any]) -> set[str]:
|
|
480
|
+
"""Every ``action`` a card can send back. Useful as ``known_actions``."""
|
|
481
|
+
found: set[str] = set()
|
|
482
|
+
|
|
483
|
+
def walk(node: Any) -> None:
|
|
484
|
+
if isinstance(node, dict):
|
|
485
|
+
sel = node.get("selection")
|
|
486
|
+
if isinstance(sel, dict) and sel.get("action"):
|
|
487
|
+
found.add(str(sel["action"]))
|
|
488
|
+
for value in node.values():
|
|
489
|
+
walk(value)
|
|
490
|
+
elif isinstance(node, list):
|
|
491
|
+
for value in node:
|
|
492
|
+
walk(value)
|
|
493
|
+
|
|
494
|
+
walk(payload)
|
|
495
|
+
return found
|
|
496
|
+
|
|
497
|
+
|
|
498
|
+
def parse_selection(text: str, known_actions: set[str] | None = None) -> Selection:
|
|
499
|
+
"""Recover a card tap from an inbound message; ``{}`` if it is ordinary text.
|
|
500
|
+
|
|
501
|
+
1. Direct @mention: the selection arrives as JSON -> returned as a dict
|
|
502
|
+
(a nested ``"selection"`` object is flattened to the top level).
|
|
503
|
+
2. Planner-routed: the selection arrives as prose that contains every
|
|
504
|
+
identifier from the selection -> we look for a known action token and
|
|
505
|
+
return ``{"action": ..., "prose": text}``.
|
|
506
|
+
"""
|
|
507
|
+
raw = (text or "").strip()
|
|
508
|
+
if not raw:
|
|
509
|
+
return {}
|
|
510
|
+
for candidate in (strip_mention(raw), raw):
|
|
511
|
+
try:
|
|
512
|
+
data = json.loads(candidate)
|
|
513
|
+
except (TypeError, ValueError):
|
|
514
|
+
continue
|
|
515
|
+
if isinstance(data, dict):
|
|
516
|
+
nested = data.get("selection")
|
|
517
|
+
if not data.get("action") and isinstance(nested, dict):
|
|
518
|
+
data = {**{k: v for k, v in data.items() if k != "selection"}, **nested}
|
|
519
|
+
return data
|
|
520
|
+
if known_actions:
|
|
521
|
+
lowered = raw.lower()
|
|
522
|
+
# Longest first so "book_confirm" wins over "book".
|
|
523
|
+
for action in sorted(known_actions, key=len, reverse=True):
|
|
524
|
+
if action and re.search(rf"(?<![\w-]){re.escape(action.lower())}(?![\w-])", lowered):
|
|
525
|
+
return {"action": action, "prose": raw}
|
|
526
|
+
return {}
|
fetch_hackkit/cli.py
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"""``hackkit`` command line: seed, whoami, handle, doctor."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import os
|
|
7
|
+
import secrets
|
|
8
|
+
import sys
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from dotenv import find_dotenv, load_dotenv
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _seed(_: argparse.Namespace) -> int:
|
|
15
|
+
from .doctor import identity_from_seed
|
|
16
|
+
|
|
17
|
+
seed = secrets.token_urlsafe(32)
|
|
18
|
+
address, wallet = identity_from_seed(seed)
|
|
19
|
+
print(f"AGENT_SEED_PHRASE={seed}\n")
|
|
20
|
+
print(f"# agent address: {address}\n# FET wallet: {wallet}")
|
|
21
|
+
print("\nPut the first line in .env. Never commit it: the seed *is* your agent.")
|
|
22
|
+
return 0
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _whoami(_: argparse.Namespace) -> int:
|
|
26
|
+
from .doctor import identity_from_seed
|
|
27
|
+
|
|
28
|
+
seed = os.getenv("AGENT_SEED_PHRASE")
|
|
29
|
+
if not seed:
|
|
30
|
+
print("AGENT_SEED_PHRASE is not set (try `hackkit seed`)", file=sys.stderr)
|
|
31
|
+
return 1
|
|
32
|
+
address, wallet = identity_from_seed(seed)
|
|
33
|
+
print(f"agent address: {address}\nFET wallet: {wallet}")
|
|
34
|
+
print(f"profile: https://agentverse.ai/agents/details/{address}/profile")
|
|
35
|
+
return 0
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _handle(args: argparse.Namespace) -> int:
|
|
39
|
+
from .register import resolve, validate_handle
|
|
40
|
+
|
|
41
|
+
try:
|
|
42
|
+
handle = validate_handle(args.handle)
|
|
43
|
+
except ValueError as exc:
|
|
44
|
+
print(f"❌ {exc}")
|
|
45
|
+
return 1
|
|
46
|
+
record = resolve(handle)
|
|
47
|
+
if record is None:
|
|
48
|
+
print(f"✅ @{handle} is available")
|
|
49
|
+
return 0
|
|
50
|
+
print(f"❌ @{handle} is taken by {record.get('address')}")
|
|
51
|
+
return 1
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _doctor(args: argparse.Namespace) -> int:
|
|
55
|
+
from .doctor import run
|
|
56
|
+
|
|
57
|
+
handle = args.handle or os.getenv("AGENT_HANDLE")
|
|
58
|
+
checks = run(readme=Path(args.readme), handle=handle)
|
|
59
|
+
for c in checks:
|
|
60
|
+
print(f"{c.icon} {c.name}: {c.detail}")
|
|
61
|
+
if c.fix and c.status != "ok":
|
|
62
|
+
print(f" fix: {c.fix}")
|
|
63
|
+
failed = sum(c.status == "fail" for c in checks)
|
|
64
|
+
print(f"\n{'All good. Ship it!' if not failed else f'{failed} check(s) failed.'}")
|
|
65
|
+
return 1 if failed else 0
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def main(argv: list[str] | None = None) -> int:
|
|
69
|
+
load_dotenv(find_dotenv(usecwd=True))
|
|
70
|
+
parser = argparse.ArgumentParser(prog="hackkit", description="Fetch.ai hackathon helpers")
|
|
71
|
+
sub = parser.add_subparsers(dest="cmd", required=True)
|
|
72
|
+
sub.add_parser("seed", help="generate a secure seed phrase + show its address").set_defaults(
|
|
73
|
+
fn=_seed
|
|
74
|
+
)
|
|
75
|
+
sub.add_parser(
|
|
76
|
+
"whoami", help="show the agent address + wallet for AGENT_SEED_PHRASE"
|
|
77
|
+
).set_defaults(fn=_whoami)
|
|
78
|
+
p = sub.add_parser("handle", help="check if an Agentverse @handle is free")
|
|
79
|
+
p.add_argument("handle")
|
|
80
|
+
p.set_defaults(fn=_handle)
|
|
81
|
+
p = sub.add_parser("doctor", help="check your agent against the submission requirements")
|
|
82
|
+
p.add_argument("--readme", default="README.md", help="README to check (default README.md)")
|
|
83
|
+
p.add_argument("--handle", help="expected @handle (default $AGENT_HANDLE)")
|
|
84
|
+
p.set_defaults(fn=_doctor)
|
|
85
|
+
args = parser.parse_args(argv)
|
|
86
|
+
return int(args.fn(args))
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
if __name__ == "__main__":
|
|
90
|
+
raise SystemExit(main())
|