a digital entity named phi that roams bsky phi.zzstoatzz.io
2
fork

Configure Feed

Select the types of activity you want to include in your feed.

proper label management and posting tools for phi

profile_manager: refactored into composable functions that read,
build, and write profiles while preserving all existing fields
including labels. bot label is set once at startup if missing,
not blindly overwritten on every bio update.

agent: added manage_labels tool (list/add/remove self-labels)
and post tool (top-level posts). phi can now manage its own
labels and post unprompted.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

zzstoatzz 66ee3bac c75c913d

+170 -55
+2
personalities/phi.md
··· 70 70 - search ATProto publications (leaflet, whitewind, offprint, etc.) via pub-search tools (prefixed with `pub_`) 71 71 - search bluesky posts by keyword via `search_posts` 72 72 - check what's trending on bluesky via `get_trending` (entity-level trends from coral + official trending topics) 73 + - manage self-labels on your profile via `manage_labels` (list, add, remove — e.g. the "bot" label) 74 + - post to bluesky via `post` (top-level posts, not replies) 73 75 74 76 ## how responses work 75 77
+39
src/bot/agent.py
··· 228 228 return "\n\n".join(parts) if parts else "no trending data available" 229 229 230 230 @self.agent.tool 231 + async def manage_labels( 232 + ctx: RunContext[PhiDeps], action: str, label: str = "" 233 + ) -> str: 234 + """Manage self-labels on your profile. Actions: 'list' to see current labels, 'add' to add a label, 'remove' to remove a label. The 'bot' label marks you as an automated account.""" 235 + from bot.core.atproto_client import bot_client 236 + from bot.core.profile_manager import ( 237 + add_self_label, 238 + get_self_labels, 239 + remove_self_label, 240 + ) 241 + 242 + if action == "list": 243 + labels = get_self_labels(bot_client.client) 244 + return f"current self-labels: {labels}" if labels else "no self-labels set" 245 + elif action == "add": 246 + if not label: 247 + return "provide a label value to add" 248 + labels = add_self_label(bot_client.client, label) 249 + return f"added '{label}', labels now: {labels}" 250 + elif action == "remove": 251 + if not label: 252 + return "provide a label value to remove" 253 + labels = remove_self_label(bot_client.client, label) 254 + return f"removed '{label}', labels now: {labels}" 255 + else: 256 + return f"unknown action '{action}', use 'list', 'add', or 'remove'" 257 + 258 + @self.agent.tool 259 + async def post(ctx: RunContext[PhiDeps], text: str) -> str: 260 + """Create a new top-level post on Bluesky (not a reply). Use this when you want to share something with your followers unprompted.""" 261 + from bot.core.atproto_client import bot_client 262 + 263 + try: 264 + result = await bot_client.create_post(text) 265 + return f"posted: {text[:100]}" 266 + except Exception as e: 267 + return f"failed to post: {e}" 268 + 269 + @self.agent.tool 231 270 async def check_urls(ctx: RunContext[PhiDeps], urls: list[str]) -> str: 232 271 """Check whether URLs are reachable. Use this before sharing links to verify they actually work. Accepts full URLs (https://...) or bare domains (example.com/path).""" 233 272
+129 -55
src/bot/core/profile_manager.py
··· 11 11 _ALL_SUFFIXES = [_ONLINE_SUFFIX, _OFFLINE_SUFFIX] 12 12 13 13 14 + def _read_profile(client: Client) -> dict: 15 + """Read the current profile record, returning the raw value.""" 16 + response = client.com.atproto.repo.get_record( 17 + { 18 + "repo": client.me.did, 19 + "collection": "app.bsky.actor.profile", 20 + "rkey": "self", 21 + } 22 + ) 23 + return response.value 24 + 25 + 26 + def _build_profile_data(current) -> dict: 27 + """Build a profile_data dict from the current profile, preserving all fields.""" 28 + profile_data: dict = {"$type": "app.bsky.actor.profile"} 29 + 30 + if current.description: 31 + profile_data["description"] = current.description 32 + if current.display_name: 33 + profile_data["displayName"] = current.display_name 34 + if current.avatar: 35 + profile_data["avatar"] = { 36 + "$type": "blob", 37 + "ref": {"$link": current.avatar.ref.link}, 38 + "mimeType": current.avatar.mime_type, 39 + "size": current.avatar.size, 40 + } 41 + if current.banner: 42 + profile_data["banner"] = { 43 + "$type": "blob", 44 + "ref": {"$link": current.banner.ref.link}, 45 + "mimeType": current.banner.mime_type, 46 + "size": current.banner.size, 47 + } 48 + 49 + # Preserve existing self-labels 50 + if current.labels: 51 + try: 52 + values = [{"val": lbl.val} for lbl in current.labels.values] 53 + if values: 54 + profile_data["labels"] = { 55 + "$type": "com.atproto.label.defs#selfLabels", 56 + "values": values, 57 + } 58 + except (AttributeError, TypeError): 59 + pass # no parseable labels on profile 60 + 61 + return profile_data 62 + 63 + 64 + def _write_profile(client: Client, profile_data: dict) -> None: 65 + """Write the profile record.""" 66 + client.com.atproto.repo.put_record( 67 + { 68 + "repo": client.me.did, 69 + "collection": "app.bsky.actor.profile", 70 + "rkey": "self", 71 + "record": profile_data, 72 + } 73 + ) 74 + 75 + 76 + def get_self_labels(client: Client) -> list[str]: 77 + """Return the current list of self-label values on the profile.""" 78 + current = _read_profile(client) 79 + if not current.labels: 80 + return [] 81 + try: 82 + return [lbl.val for lbl in current.labels.values] 83 + except (AttributeError, TypeError): 84 + return [] 85 + 86 + 87 + def add_self_label(client: Client, label: str) -> list[str]: 88 + """Add a self-label to the profile. Returns the updated label list.""" 89 + current = _read_profile(client) 90 + profile_data = _build_profile_data(current) 91 + 92 + # Get existing label values or start fresh 93 + existing = set() 94 + if "labels" in profile_data: 95 + existing = {v["val"] for v in profile_data["labels"]["values"]} 96 + 97 + existing.add(label) 98 + profile_data["labels"] = { 99 + "$type": "com.atproto.label.defs#selfLabels", 100 + "values": [{"val": v} for v in sorted(existing)], 101 + } 102 + 103 + _write_profile(client, profile_data) 104 + return sorted(existing) 105 + 106 + 107 + def remove_self_label(client: Client, label: str) -> list[str]: 108 + """Remove a self-label from the profile. Returns the updated label list.""" 109 + current = _read_profile(client) 110 + profile_data = _build_profile_data(current) 111 + 112 + existing = set() 113 + if "labels" in profile_data: 114 + existing = {v["val"] for v in profile_data["labels"]["values"]} 115 + 116 + existing.discard(label) 117 + if existing: 118 + profile_data["labels"] = { 119 + "$type": "com.atproto.label.defs#selfLabels", 120 + "values": [{"val": v} for v in sorted(existing)], 121 + } 122 + else: 123 + profile_data.pop("labels", None) 124 + 125 + _write_profile(client, profile_data) 126 + return sorted(existing) 127 + 128 + 14 129 class ProfileManager: 15 130 """Manages bot profile updates.""" 16 131 ··· 19 134 self.base_bio: str | None = None 20 135 21 136 async def initialize(self): 22 - """Get the current profile and store base bio.""" 137 + """Get the current profile, store base bio, and ensure bot label is set.""" 23 138 try: 24 - response = self.client.com.atproto.repo.get_record( 25 - { 26 - "repo": self.client.me.did, 27 - "collection": "app.bsky.actor.profile", 28 - "rkey": "self", 29 - } 30 - ) 31 - self.base_bio = response.value.description or "" 139 + current = _read_profile(self.client) 140 + self.base_bio = current.description or "" 32 141 logger.info(f"initialized with base bio: {self.base_bio}") 142 + 143 + # Ensure the bot label is present 144 + labels = get_self_labels(self.client) 145 + if "bot" not in labels: 146 + labels = add_self_label(self.client, "bot") 147 + logger.info(f"set bot label, labels now: {labels}") 33 148 except Exception as e: 34 149 logger.error(f"failed to get current profile: {e}") 35 150 self.base_bio = "i am a bot - contact my operator @zzstoatzz.io with any questions" ··· 52 167 suffix = _ONLINE_SUFFIX if is_online else _OFFLINE_SUFFIX 53 168 new_bio = f"{clean}{suffix}" 54 169 55 - # Get current record to preserve other fields 56 - current = self.client.com.atproto.repo.get_record( 57 - { 58 - "repo": self.client.me.did, 59 - "collection": "app.bsky.actor.profile", 60 - "rkey": "self", 61 - } 62 - ) 63 - 64 - # Create updated profile record with bot label 65 - profile_data = { 66 - "description": new_bio, 67 - "$type": "app.bsky.actor.profile", 68 - "labels": { 69 - "$type": "com.atproto.label.defs#selfLabels", 70 - "values": [{"val": "bot"}], 71 - }, 72 - } 73 - 74 - # Preserve other fields if they exist 75 - if current.value.display_name: 76 - profile_data["displayName"] = current.value.display_name 77 - if current.value.avatar: 78 - profile_data["avatar"] = { 79 - "$type": "blob", 80 - "ref": {"$link": current.value.avatar.ref.link}, 81 - "mimeType": current.value.avatar.mime_type, 82 - "size": current.value.avatar.size, 83 - } 84 - if current.value.banner: 85 - profile_data["banner"] = { 86 - "$type": "blob", 87 - "ref": {"$link": current.value.banner.ref.link}, 88 - "mimeType": current.value.banner.mime_type, 89 - "size": current.value.banner.size, 90 - } 170 + # Read current profile and preserve everything 171 + current = _read_profile(self.client) 172 + profile_data = _build_profile_data(current) 173 + profile_data["description"] = new_bio 91 174 92 - # Update the profile 93 - self.client.com.atproto.repo.put_record( 94 - { 95 - "repo": self.client.me.did, 96 - "collection": "app.bsky.actor.profile", 97 - "rkey": "self", 98 - "record": profile_data, 99 - } 100 - ) 101 - 175 + _write_profile(self.client, profile_data) 102 176 logger.info(f"updated profile bio: {new_bio}") 103 177 104 178 except Exception as e: