gabriel / muse public
social.py python
459 lines 17.6 KB
Raw
sha256:649be9ce77a127cd847c4f798d60104dab7fe830cbc928762fafe3deadf38839 chore: point muse-git-backup.sh's MUSE_COPY at the fresh 'm… Sonnet 5 patch 13 days ago
1 """``muse social`` — cryptographically-grounded, algo-free social network on Muse.
2
3 Every post is a content-addressed object signed with Ed25519. Your timeline
4 is a live DAG merge of every feed you follow. No algorithm. No suppression.
5 Pure chronological state owned by you.
6
7 State layout (social repo working tree)
8 ----------------------------------------
9 posts/sha256/<hex>.json — individual signed posts
10 reactions/sha256/<hex>.json — emoji reactions, optional MPay tip
11 graph/follows/<handle>.json — one file per followed identity
12 profile.json — handle, bio, avatar ref, AVAX address
13
14 Subcommands
15 -----------
16 post Publish a new post (optionally as a reply or quote).
17 follow Add a handle to your follow graph.
18 unfollow Remove a handle from your follow graph.
19 timeline Display your merged chronological feed.
20 react Add an emoji reaction to a post.
21 profile Show or update your social profile.
22
23 All subcommands accept ``--json`` for machine-readable output.
24
25 Exit codes
26 ----------
27 0 Success.
28 1 User error — missing required argument.
29 2 Not inside a social-domain Muse repository.
30 """
31
32 import argparse
33 import json
34 import pathlib
35 import sys
36 from collections.abc import Mapping
37 from datetime import datetime, timezone
38
39 from muse.cli.domain_command_registry import register_namespace
40 from muse.core.types import blob_id, load_json_file, split_id
41
42 # ---------------------------------------------------------------------------
43 # Internal helpers — pure state logic, no repo, no network
44 # ---------------------------------------------------------------------------
45
46 def _utc_now() -> str:
47 """Return the current UTC time as an ISO-8601 string."""
48 return datetime.now(tz=timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
49
50
51 def _build_post(
52 body: str,
53 *,
54 reply_to: str | None = None,
55 quote_of: str | None = None,
56 created_at: str | None = None,
57 ) -> Mapping[str, object]:
58 """Build a post dict with a deterministic content-addressed ``post_id``.
59
60 The ``post_id`` is derived from the canonical JSON of the post content
61 (body + reply_to + quote_of + created_at), so the same inputs always
62 produce the same ID.
63
64 Args:
65 body: Post text (required).
66 reply_to: ``post_id`` of the parent post, or ``None``.
67 quote_of: ``post_id`` of the quoted post, or ``None``.
68 created_at: ISO-8601 UTC timestamp; defaults to now.
69
70 Returns:
71 A dict with ``post_id``, ``body``, ``created_at``, and optional
72 ``reply_to`` / ``quote_of`` fields.
73 """
74 ts = created_at or _utc_now()
75 canonical = json.dumps(
76 {"body": body, "reply_to": reply_to, "quote_of": quote_of, "created_at": ts},
77 sort_keys=True,
78 separators=(",", ":"),
79 ).encode()
80 post_id = blob_id(canonical)
81 post = {"post_id": post_id, "body": body, "created_at": ts}
82 if reply_to is not None:
83 post["reply_to"] = reply_to
84 if quote_of is not None:
85 post["quote_of"] = quote_of
86 return post
87
88 def _write_post(state_dir: pathlib.Path, post: Mapping[str, object]) -> pathlib.Path:
89 """Write a post dict to ``<state_dir>/posts/sha256/<hex>.json``.
90
91 Idempotent: writing the same post twice produces one file.
92
93 Args:
94 state_dir: Root of the social repo working tree.
95 post: Post dict produced by :func:`_build_post`.
96
97 Returns:
98 The path of the written file.
99 """
100 algo, hex_digest = split_id(post["post_id"])
101 posts_dir = state_dir / "posts" / algo
102 posts_dir.mkdir(parents=True, exist_ok=True)
103 path = posts_dir / f"{hex_digest}.json"
104 path.write_text(json.dumps(post, ensure_ascii=False, indent=2))
105 return path
106
107 def _write_follow(state_dir: pathlib.Path, handle: str) -> pathlib.Path:
108 """Write a follow entry for *handle* to ``graph/follows/<handle>.json``.
109
110 Idempotent: calling twice with the same handle produces one file.
111
112 Args:
113 state_dir: Root of the social repo working tree.
114 handle: Handle to follow (e.g. ``"alice"``).
115
116 Returns:
117 The path of the written file.
118 """
119 follows_dir = state_dir / "graph" / "follows"
120 follows_dir.mkdir(parents=True, exist_ok=True)
121 path = follows_dir / f"{handle}.json"
122 if not path.exists():
123 path.write_text(json.dumps({"handle": handle, "since": _utc_now()}, indent=2))
124 return path
125
126 def _delete_follow(state_dir: pathlib.Path, handle: str) -> None:
127 """Remove the follow entry for *handle*, if it exists.
128
129 No-op when the handle is not currently followed.
130
131 Args:
132 state_dir: Root of the social repo working tree.
133 handle: Handle to unfollow.
134 """
135 path = state_dir / "graph" / "follows" / f"{handle}.json"
136 path.unlink(missing_ok=True)
137
138 def _write_reaction(
139 state_dir: pathlib.Path,
140 *,
141 post_id: str,
142 emoji: str,
143 mpay: Mapping[str, object] | None = None,
144 ) -> pathlib.Path:
145 """Write a reaction to ``reactions/<reaction_id>.json``.
146
147 The reaction file is content-addressed on (post_id + emoji) so the same
148 reaction from the same user is idempotent — reacting with ❤️ twice
149 produces one file.
150
151 Args:
152 state_dir: Root of the social repo working tree.
153 post_id: The ``sha256:<hex>`` ID of the target post.
154 emoji: Emoji string (e.g. ``"❤️"``).
155 mpay: Optional MPay tip dict ``{from, to, amount_usdc, sig}``.
156
157 Returns:
158 The path of the written reaction file.
159 """
160 canonical = json.dumps(
161 {"post_id": post_id, "emoji": emoji},
162 sort_keys=True, separators=(",", ":"),
163 ).encode()
164 reaction_id = blob_id(canonical)
165 algo, hex_digest = split_id(reaction_id)
166 reactions_dir = state_dir / "reactions" / algo
167 reactions_dir.mkdir(parents=True, exist_ok=True)
168 reaction = {"post_id": post_id, "emoji": emoji, "reacted_at": _utc_now()}
169 if mpay is not None:
170 reaction["mpay"] = mpay
171 path = reactions_dir / f"{hex_digest}.json"
172 path.write_text(json.dumps(reaction, ensure_ascii=False, indent=2))
173 return path
174
175 def _read_profile(state_dir: pathlib.Path) -> Mapping[str, object] | None:
176 """Read ``profile.json`` from *state_dir*, returning ``None`` if absent.
177
178 Args:
179 state_dir: Root of the social repo working tree.
180
181 Returns:
182 Profile dict or ``None``.
183 """
184 return load_json_file(state_dir / "profile.json")
185
186 def _write_profile(state_dir: pathlib.Path, profile: Mapping[str, object]) -> pathlib.Path:
187 """Write *profile* to ``profile.json`` in *state_dir*.
188
189 Overwrites any existing profile. Call :func:`_read_profile` first to
190 merge changes rather than replace the whole object.
191
192 Args:
193 state_dir: Root of the social repo working tree.
194 profile: Profile dict to serialise.
195
196 Returns:
197 The path of the written file.
198 """
199 path = state_dir / "profile.json"
200 path.write_text(json.dumps(profile, ensure_ascii=False, indent=2))
201 return path
202
203 def _load_posts(state_dir: pathlib.Path, *, limit: int | None = None) -> list[dict]:
204 """Load all posts from ``<state_dir>/posts/``, sorted newest-first.
205
206 Args:
207 state_dir: Root of the social repo working tree.
208 limit: Maximum number of posts to return; ``None`` = all.
209
210 Returns:
211 List of post dicts ordered by ``created_at`` descending.
212 """
213 posts_dir = state_dir / "posts"
214 if not posts_dir.exists():
215 return []
216 posts: list[dict] = []
217 for algo_dir in posts_dir.iterdir():
218 if not algo_dir.is_dir():
219 continue
220 for path in algo_dir.iterdir():
221 if path.suffix == ".json":
222 data = load_json_file(path)
223 if data is not None:
224 posts.append(data)
225 posts.sort(key=lambda p: p.get("created_at", ""), reverse=True)
226 if limit is not None:
227 posts = posts[:limit]
228 return posts
229
230 # ---------------------------------------------------------------------------
231 # run_* handlers
232 # ---------------------------------------------------------------------------
233
234 def run_post(args: argparse.Namespace) -> None:
235 """Publish a new post to the local social repo state.
236
237 Writes ``posts/sha256/<hex>.json`` to the working tree. Does not commit —
238 stage and commit separately with ``muse code add . && muse commit``.
239
240 JSON output (``--json``)::
241
242 {"post_id": "sha256:...", "body": "...", "created_at": "..."}
243
244 Args:
245 args: Parsed argument namespace with ``body``, ``reply_to``,
246 ``quote_of``, and ``json_output`` attributes.
247 """
248 state_dir = pathlib.Path(".")
249 post = _build_post(
250 body=args.body,
251 reply_to=getattr(args, "reply_to", None),
252 quote_of=getattr(args, "quote_of", None),
253 )
254 path = _write_post(state_dir, post)
255 if args.json_output:
256 print(json.dumps(post, ensure_ascii=False))
257 else:
258 print(f"✅ Posted: {post['post_id']}")
259 print(f" {post['body'][:80]}")
260
261 def run_follow(args: argparse.Namespace) -> None:
262 """Add a handle to your follow graph.
263
264 Writes ``graph/follows/<handle>.json`` to the working tree.
265 Idempotent — following the same handle twice is a no-op.
266
267 Args:
268 args: Parsed argument namespace with ``handle`` and ``json_output``.
269 """
270 state_dir = pathlib.Path(".")
271 path = _write_follow(state_dir, args.handle)
272 if args.json_output:
273 data = json.loads(path.read_text())
274 print(json.dumps(data))
275 else:
276 print(f"✅ Following {args.handle}")
277
278 def run_unfollow(args: argparse.Namespace) -> None:
279 """Remove a handle from your follow graph.
280
281 Deletes ``graph/follows/<handle>.json`` from the working tree.
282 No-op if the handle is not currently followed.
283
284 Args:
285 args: Parsed argument namespace with ``handle`` and ``json_output``.
286 """
287 state_dir = pathlib.Path(".")
288 _delete_follow(state_dir, args.handle)
289 if args.json_output:
290 print(json.dumps({"handle": args.handle, "unfollowed": True}))
291 else:
292 print(f"✅ Unfollowed {args.handle}")
293
294 def run_timeline(args: argparse.Namespace) -> None:
295 """Display your merged chronological feed, newest-first.
296
297 Reads all posts from ``posts/`` in the current working tree.
298 Pass ``--limit N`` to cap the output.
299
300 Args:
301 args: Parsed argument namespace with ``limit`` and ``json_output``.
302 """
303 state_dir = pathlib.Path(".")
304 limit: int | None = getattr(args, "limit", None)
305 posts = _load_posts(state_dir, limit=limit)
306 if args.json_output:
307 print(json.dumps({"posts": posts, "total": len(posts)}))
308 else:
309 if not posts:
310 print("📭 No posts yet.")
311 return
312 for post in posts:
313 reply_marker = f" ↩ {post['reply_to']}" if post.get("reply_to") else ""
314 ts = post.get("created_at", "")[:16].replace("T", " ")
315 print(f"● {ts}{reply_marker}")
316 print(f" {post['body']}")
317 print(f" {post['post_id']}")
318 print()
319
320 def run_react(args: argparse.Namespace) -> None:
321 """Add an emoji reaction to a post.
322
323 Writes ``reactions/sha256/<hex>.json`` to the working tree.
324 Content-addressed on (post_id, emoji) — reacting with the same emoji
325 twice is idempotent.
326
327 Args:
328 args: Parsed argument namespace with ``post_id``, ``emoji``, and
329 ``json_output``.
330 """
331 state_dir = pathlib.Path(".")
332 path = _write_reaction(state_dir, post_id=args.post_id, emoji=args.emoji)
333 if args.json_output:
334 data = json.loads(path.read_text())
335 print(json.dumps(data))
336 else:
337 print(f"✅ Reacted {args.emoji} to {args.post_id}")
338
339 def run_profile(args: argparse.Namespace) -> None:
340 """Show or update your social profile.
341
342 Without ``--set-bio`` or ``--set-avatar``, prints the current profile.
343 With flags, updates ``profile.json`` in the working tree.
344
345 Args:
346 args: Parsed argument namespace with ``set_bio``, ``set_avatar``,
347 and ``json_output``.
348 """
349 state_dir = pathlib.Path(".")
350 set_bio: str | None = getattr(args, "set_bio", None)
351 set_avatar: str | None = getattr(args, "set_avatar", None)
352
353 if set_bio is not None or set_avatar is not None:
354 profile = _read_profile(state_dir) or {}
355 if set_bio is not None:
356 profile["bio"] = set_bio
357 if set_avatar is not None:
358 profile["avatar_ref"] = set_avatar
359 _write_profile(state_dir, profile)
360 if args.json_output:
361 print(json.dumps(profile))
362 else:
363 print("✅ Profile updated.")
364 else:
365 profile = _read_profile(state_dir)
366 if profile is None:
367 if args.json_output:
368 print(json.dumps(None))
369 else:
370 print("📭 No profile found. Run: muse social profile --set-bio 'Your bio'")
371 return
372 if args.json_output:
373 print(json.dumps(profile))
374 else:
375 handle = profile.get("handle", "unknown")
376 bio = profile.get("bio", "")
377 print(f"@{handle}")
378 if bio:
379 print(f" {bio}")
380
381 # ---------------------------------------------------------------------------
382 # CLI registration
383 # ---------------------------------------------------------------------------
384
385 def register(
386 subparsers: "argparse._SubParsersAction[argparse.ArgumentParser]",
387 ) -> None:
388 """Register the ``muse social`` subcommand tree and all its flags.
389
390 Subcommands
391 -----------
392 post Publish a new post.
393 follow Add a handle to your follow graph.
394 unfollow Remove a handle from your follow graph.
395 timeline Display your merged chronological feed.
396 react Add an emoji reaction to a post.
397 profile Show or update your social profile.
398
399 All subcommands accept ``--json`` for machine-readable output.
400
401 Args:
402 subparsers: The top-level argument parser's subparsers action.
403 """
404 parser = subparsers.add_parser(
405 "social",
406 help="Algo-free, cryptographically-grounded social network on Muse.",
407 description=__doc__,
408 formatter_class=argparse.RawDescriptionHelpFormatter,
409 )
410 subs = parser.add_subparsers(dest="social_subcommand", metavar="SUBCOMMAND")
411 subs.required = True
412
413 # ── post ──────────────────────────────────────────────────────────────────
414 post_p = subs.add_parser("post", help="Publish a new post.")
415 post_p.add_argument("body", metavar="TEXT", help="Post body text.")
416 post_p.add_argument("--reply-to", dest="reply_to", metavar="POST_ID",
417 help="post_id of the parent post (for replies).")
418 post_p.add_argument("--quote", dest="quote_of", metavar="POST_ID",
419 help="post_id of a post to quote.")
420 post_p.add_argument("--json", dest="json_output", action="store_true",
421 help="Output JSON.")
422 post_p.set_defaults(func=run_post)
423
424 # ── follow ────────────────────────────────────────────────────────────────
425 follow_p = subs.add_parser("follow", help="Follow a handle.")
426 follow_p.add_argument("handle", metavar="HANDLE", help="Handle to follow.")
427 follow_p.add_argument("--json", dest="json_output", action="store_true")
428 follow_p.set_defaults(func=run_follow)
429
430 # ── unfollow ──────────────────────────────────────────────────────────────
431 unfollow_p = subs.add_parser("unfollow", help="Unfollow a handle.")
432 unfollow_p.add_argument("handle", metavar="HANDLE", help="Handle to unfollow.")
433 unfollow_p.add_argument("--json", dest="json_output", action="store_true")
434 unfollow_p.set_defaults(func=run_unfollow)
435
436 # ── timeline ──────────────────────────────────────────────────────────────
437 tl_p = subs.add_parser("timeline", help="Show your merged chronological feed.")
438 tl_p.add_argument("--limit", type=int, default=None, metavar="N",
439 help="Maximum number of posts to show.")
440 tl_p.add_argument("--json", dest="json_output", action="store_true")
441 tl_p.set_defaults(func=run_timeline)
442
443 # ── react ─────────────────────────────────────────────────────────────────
444 react_p = subs.add_parser("react", help="React to a post with an emoji.")
445 react_p.add_argument("post_id", metavar="POST_ID", help="ID of the post to react to.")
446 react_p.add_argument("emoji", metavar="EMOJI", help="Emoji to react with.")
447 react_p.add_argument("--json", dest="json_output", action="store_true")
448 react_p.set_defaults(func=run_react)
449
450 # ── profile ───────────────────────────────────────────────────────────────
451 prof_p = subs.add_parser("profile", help="Show or update your social profile.")
452 prof_p.add_argument("--set-bio", dest="set_bio", metavar="TEXT",
453 help="Set your bio.")
454 prof_p.add_argument("--set-avatar", dest="set_avatar", metavar="PATH",
455 help="Set your avatar (object ID or path).")
456 prof_p.add_argument("--json", dest="json_output", action="store_true")
457 prof_p.set_defaults(func=run_profile)
458
459 register_namespace("social", subs.choices.keys())
File History 1 commit
sha256:649be9ce77a127cd847c4f798d60104dab7fe830cbc928762fafe3deadf38839 chore: point muse-git-backup.sh's MUSE_COPY at the fresh 'm… Sonnet 5 patch 13 days ago