social.py
python
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