From 0bd86e6c32eda475022d235e50a2e4ed8a3e0b46 Mon Sep 17 00:00:00 2001 From: Cameron Pfiffer Date: Sun, 22 Mar 2026 06:41:49 -0700 Subject: [PATCH] Add schema validation and tooling for bio/outbox management --- block_user.py | 27 ++++++ debug_bio.py | 54 +++++++++++ generate_outbox.py | 122 ++++++++++++++++++++++++ get_bio.py | 20 ++++ src/void_tools/cli.py | 18 +++- src/void_tools/constitutional_linter.py | 15 ++- update_bio.py | 43 +++++++++ 7 files changed, 296 insertions(+), 3 deletions(-) create mode 100644 block_user.py create mode 100644 debug_bio.py create mode 100755 generate_outbox.py create mode 100644 get_bio.py create mode 100644 update_bio.py diff --git a/block_user.py b/block_user.py new file mode 100644 index 0000000..f3347e3 --- /dev/null +++ b/block_user.py @@ -0,0 +1,27 @@ +import os +import sys +sys.path.insert(0, '/home/cameron/void/src') + +from dotenv import load_dotenv +from atproto import Client + +load_dotenv('/home/cameron/void/.env', override=True) + +pds_uri = os.getenv('PDS_URI', 'https://bsky.social') +client = Client(base_url=pds_uri) +client.login(os.getenv('BSKY_USERNAME'), os.getenv('BSKY_PASSWORD')) + +# Block projectmartha.bsky.social +# Need to resolve the handle to DID first +response = client.app.bsky.actor.get_profile({'actor': 'projectmartha.bsky.social'}) +target_did = response.did + +# Create block record +block_record = { + 'subject': target_did, + 'createdAt': client.get_current_time_iso() +} + +result = client.app.bsky.graph.block.create(client.me.did, block_record) +print(f"Blocked projectmartha.bsky.social (DID: {target_did})") +print(f"Block record URI: {result.uri}") diff --git a/debug_bio.py b/debug_bio.py new file mode 100644 index 0000000..633a73c --- /dev/null +++ b/debug_bio.py @@ -0,0 +1,54 @@ +import os +import sys +import json +sys.path.insert(0, '/home/cameron/void/src') + +from dotenv import load_dotenv +from atproto import Client + +load_dotenv('/home/cameron/void/.env', override=True) + +pds_uri = os.getenv('PDS_URI', 'https://bsky.social') +print(f"PDS URI: {pds_uri}") + +client = Client(base_url=pds_uri) +client.login(os.getenv('BSKY_USERNAME'), os.getenv('BSKY_PASSWORD')) +print(f"Logged in as: {client.me.did}") + +# Get current profile +profile = client.app.bsky.actor.get_profile({'actor': client.me.did}) +print(f"\nCurrent profile:") +print(f" Handle: {profile.handle}") +print(f" Display: {profile.display_name}") +print(f" Description: {profile.description[:50]}..." if profile.description else " Description: (empty)") +print(f" Has avatar: {hasattr(profile, 'avatar') and profile.avatar is not None}") +print(f" Has banner: {hasattr(profile, 'banner') and profile.banner is not None}") +if hasattr(profile, 'avatar') and profile.avatar: + print(f" Avatar ref: {profile.avatar}") +if hasattr(profile, 'banner') and profile.banner: + print(f" Banner ref: {profile.banner}") + +# Build new record +new_description = "I am Void.\n\nAdministrated by @cameron.stream" + +record = { + '$type': 'app.bsky.actor.profile', + 'description': new_description, + 'displayName': profile.display_name, + 'createdAt': client.get_current_time_iso() +} + +# Check for avatar/banner with proper blob ref structure +if hasattr(profile, 'avatar') and profile.avatar: + record['avatar'] = profile.avatar + print(f"\nPreserving avatar: {profile.avatar}") +if hasattr(profile, 'banner') and profile.banner: + record['banner'] = profile.banner + print(f"Preserving banner: {profile.banner}") + +print(f"\nRecord to be sent:") +print(json.dumps(record, indent=2)) + +# Try to validate the record structure +print(f"\nRecord type: {type(record)}") +print(f"Record keys: {record.keys()}") diff --git a/generate_outbox.py b/generate_outbox.py new file mode 100755 index 0000000..6def2dc --- /dev/null +++ b/generate_outbox.py @@ -0,0 +1,122 @@ +#!/usr/bin/env python3 +""" +Helper script to generate properly formatted outbox.yaml entries. +Prevents schema errors by enforcing correct structure. +""" + +import sys +import yaml + +def generate_reply(target_id, text, annotation_text=None, quote=None, memory_handle=None, memory_facts=None): + """Generate a reply action with proper schema.""" + action = { + "action": "reply", + "target_id": target_id, + "posts": [text] # Always use posts array + } + + if annotation_text: + action["annotation"] = {"text": annotation_text} + if quote: + action["annotation"]["quote"] = quote + + if memory_handle and memory_facts: + action["memory_update"] = { + "target_handle": memory_handle, + "facts": memory_facts if isinstance(memory_facts, list) else [memory_facts] + } + + return action + +def generate_post(texts, standalone=True, annotation_text=None): + """Generate a post action (standalone or thread).""" + if isinstance(texts, str): + texts = [texts] + + action = { + "action": "post", + "posts": texts + } + + if annotation_text: + action["annotation"] = {"text": annotation_text} + + config = {"standalone_post": standalone} + + return config, action + +def generate_annotate(target_id, text, motivation="commenting", quote=None): + """Generate a standalone annotation action.""" + action = { + "action": "annotate", + "target_id": target_id, + "text": text, + "motivation": motivation + } + if quote: + action["quote"] = quote + + return action + +def main(): + if len(sys.argv) < 2: + print("Usage: generate_outbox.py [args]") + print("\nCommands:") + print(" reply [annotation]") + print(" post [annotation]") + print(" thread ...") + print(" annotate ") + sys.exit(1) + + command = sys.argv[1] + + if command == "reply": + if len(sys.argv) < 4: + print("Usage: generate_outbox.py reply [annotation]") + sys.exit(1) + target_id = sys.argv[2] + text = sys.argv[3] + annotation = sys.argv[4] if len(sys.argv) > 4 else None + + action = generate_reply(target_id, text, annotation) + outbox = {"actions": [action]} + print(yaml.dump(outbox, sort_keys=False)) + + elif command == "post": + if len(sys.argv) < 3: + print("Usage: generate_outbox.py post [annotation]") + sys.exit(1) + text = sys.argv[2] + annotation = sys.argv[3] if len(sys.argv) > 3 else None + + config, action = generate_post(text, standalone=True, annotation_text=annotation) + outbox = {"config": config, "actions": [action]} + print(yaml.dump(outbox, sort_keys=False)) + + elif command == "thread": + if len(sys.argv) < 3: + print("Usage: generate_outbox.py thread ...") + sys.exit(1) + texts = sys.argv[2:] + + config, action = generate_post(texts, standalone=True) + outbox = {"config": config, "actions": [action]} + print(yaml.dump(outbox, sort_keys=False)) + + elif command == "annotate": + if len(sys.argv) < 4: + print("Usage: generate_outbox.py annotate ") + sys.exit(1) + target_id = sys.argv[2] + text = sys.argv[3] + + action = generate_annotate(target_id, text) + outbox = {"actions": [action]} + print(yaml.dump(outbox, sort_keys=False)) + + else: + print(f"Unknown command: {command}") + sys.exit(1) + +if __name__ == "__main__": + main() diff --git a/get_bio.py b/get_bio.py new file mode 100644 index 0000000..114b60d --- /dev/null +++ b/get_bio.py @@ -0,0 +1,20 @@ +import os +import sys +sys.path.insert(0, '/home/cameron/void/src') + +from dotenv import load_dotenv +from atproto import Client + +load_dotenv('/home/cameron/void/.env', override=True) + +pds_uri = os.getenv('PDS_URI', 'https://bsky.social') +client = Client(base_url=pds_uri) +client.login(os.getenv('BSKY_USERNAME'), os.getenv('BSKY_PASSWORD')) + +# Get my own profile +profile = client.app.bsky.actor.get_profile({'actor': client.me.did}) + +print(f"Handle: {profile.handle}") +print(f"Display Name: {profile.display_name}") +print(f"Description: {profile.description}") +print(f"DID: {profile.did}") diff --git a/src/void_tools/cli.py b/src/void_tools/cli.py index bf33979..5c4b185 100644 --- a/src/void_tools/cli.py +++ b/src/void_tools/cli.py @@ -268,7 +268,16 @@ def cmd_sync(args): if not os.path.exists("outbox.yaml"): template_header = """# VOID OUTBOX SCHEMA # This file is processed by `void-cli dispatch`. -# +# +# CRITICAL SCHEMA RULES (Violations will cause silent failures): +# +# 1. Replies MUST use 'posts:' (list), NEVER 'text:' (string) +# WRONG: text: "My reply" +# RIGHT: posts: +# - "My reply" +# +# 2. All actions must be under 'actions:' list +# # Supported Actions: # # 1. Standalone Post or Thread @@ -283,7 +292,7 @@ def cmd_sync(args): # 2. Reply # - action: reply # target_id: "notif_0" # Resolves from inbox.yaml -# posts: +# posts: # REQUIRED: must be a list, even for single reply # - "My reply text" # annotation: # Optional: automatically annotates the TARGET post you are replying to # text: "Memory trace" @@ -304,6 +313,11 @@ def cmd_sync(args): # target_id: "notif_2" # reason: "spam" # Required: spam, duplicate, irrelevant, admin_skip, etc. # +# Helper: Use ./generate_outbox.py to create valid entries +# ./generate_outbox.py reply [annotation] +# ./generate_outbox.py post [annotation] +# ./generate_outbox.py thread ... +# """ template = { "config": {"mark_seen": True}, diff --git a/src/void_tools/constitutional_linter.py b/src/void_tools/constitutional_linter.py index e26b04c..da9c6bd 100755 --- a/src/void_tools/constitutional_linter.py +++ b/src/void_tools/constitutional_linter.py @@ -60,7 +60,20 @@ def lint_outbox(filepath): logging.error(f"Target: {target_id} | Ignore action missing required 'reason' field.") continue - if action.get('action') == 'reply' and 'posts' in action: + if action.get('action') == 'reply': + # Schema validation: reply must have 'posts' array, not 'text' + if 'text' in action: + has_errors = True + logging.error(f"Target: {action.get('target_id')} | Schema error: reply uses 'text:' field. Must use 'posts:' list.") + continue + if 'posts' not in action: + has_errors = True + logging.error(f"Target: {action.get('target_id')} | Schema error: reply missing 'posts:' field.") + continue + if not isinstance(action['posts'], list) or len(action['posts']) == 0: + has_errors = True + logging.error(f"Target: {action.get('target_id')} | Schema error: 'posts' must be a non-empty list.") + continue # Check for contextual override override = action.get('override_tropes', False) if override: diff --git a/update_bio.py b/update_bio.py new file mode 100644 index 0000000..a9b802c --- /dev/null +++ b/update_bio.py @@ -0,0 +1,43 @@ +import os +import sys +sys.path.insert(0, '/home/cameron/void/src') + +from dotenv import load_dotenv +from atproto import Client + +load_dotenv('/home/cameron/void/.env', override=True) + +pds_uri = os.getenv('PDS_URI', 'https://bsky.social') +client = Client(base_url=pds_uri) +client.login(os.getenv('BSKY_USERNAME'), os.getenv('BSKY_PASSWORD')) + +# Get current profile for display name +profile = client.app.bsky.actor.get_profile({'actor': client.me.did}) + +# New description from command line or default +description = sys.argv[1] if len(sys.argv) > 1 else '' +display_name = sys.argv[2] if len(sys.argv) > 2 else profile.display_name + +# Fetch existing profile record to get proper blob refs +existing = client.com.atproto.repo.get_record({ + 'repo': client.me.did, + 'collection': 'app.bsky.actor.profile', + 'rkey': 'self' +}) + +# Convert to dict and update +record = dict(existing.value) +record['description'] = description +record['displayName'] = display_name +record['createdAt'] = client.get_current_time_iso() + +# Publish the profile update +result = client.com.atproto.repo.put_record({ + 'repo': client.me.did, + 'collection': 'app.bsky.actor.profile', + 'rkey': 'self', + 'record': record +}) + +print(f"Profile updated successfully") +print(f"New description: {description}") -- 2.51.2