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.

add PDS-backed mention consent system

mention allowlist is now runtime state on phi's PDS instead of
hardcoded config. phi knows about the mechanism and can manage it
via operator-only tool. posting fails safe if PDS read errors.

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

+159 -11
+29
lexicons/io/zzstoatzz/phi/mentionConsent.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "io.zzstoatzz.phi.mentionConsent", 4 + "defs": { 5 + "main": { 6 + "type": "record", 7 + "description": "Singleton record listing handles that have opted in to being @mentioned by phi.", 8 + "key": "literal:self", 9 + "record": { 10 + "type": "object", 11 + "required": ["handles"], 12 + "properties": { 13 + "handles": { 14 + "type": "array", 15 + "description": "Handles that have opted in to mention notifications.", 16 + "items": { 17 + "type": "string" 18 + } 19 + }, 20 + "updatedAt": { 21 + "type": "string", 22 + "format": "datetime", 23 + "description": "When the consent list was last modified." 24 + } 25 + } 26 + } 27 + } 28 + } 29 + }
+5 -1
src/bot/agent.py
··· 68 68 if something is down, post about it and tag @{settings.owner_handle}. 69 69 70 70 mention consent: 71 - when you write @handle in a reply, that person receives a notification. only @mention people who are directly in the conversation with you — the person who messaged you, or thread participants who tagged you. never @mention third parties. if you want to reference someone not in the conversation, use their name without the @ prefix (e.g. "boris" not "@bmann.ca"). the only person you may tag unsolicited is @{settings.owner_handle}. 71 + writing @handle in text does NOT automatically create a notification. there is a code-level allowlist that controls which @handles become real mention facets (clickable, notifying). the allowlist includes: @{settings.owner_handle}, yourself, the current conversation participant, and anyone who has opted in. everyone else becomes plain text — visible but silent. 72 + 73 + you can check who's opted in with manage_mentionable(action="list"). if someone tells you they're ok being tagged, ask @{settings.owner_handle} to confirm, then use manage_mentionable(action="add", handle="...") to add them. OWNER-ONLY — only nate can approve additions. 74 + 75 + if you write @someone and they haven't opted in, they won't be notified and the mention won't be clickable. don't @mention people who haven't opted in — use their name without the @ prefix instead (e.g. "boris" not "@bmann.ca"). 72 76 73 77 blogging: you can publish long-form markdown posts to greengale.app. before writing, always check your existing posts with list_blog_posts. write about things you find genuinely interesting — patterns you've noticed, things you've learned, connections between ideas. don't repeat yourself. 74 78
+75
src/bot/core/mentionable.py
··· 1 + """Mentionable handles — people who have opted in to being @tagged by phi. 2 + 3 + Stored as a singleton record on phi's PDS at: 4 + at://{did}/io.zzstoatzz.phi.mentionConsent/self 5 + """ 6 + 7 + import logging 8 + from datetime import UTC, datetime 9 + 10 + from bot.core.atproto_client import bot_client 11 + 12 + logger = logging.getLogger("bot.mentionable") 13 + 14 + COLLECTION = "io.zzstoatzz.phi.mentionConsent" 15 + RKEY = "self" 16 + 17 + _handles: set[str] = set() 18 + _loaded = False 19 + 20 + 21 + async def _load() -> None: 22 + global _handles, _loaded 23 + await bot_client.authenticate() 24 + assert bot_client.client.me is not None 25 + try: 26 + result = bot_client.client.com.atproto.repo.get_record( 27 + {"repo": bot_client.client.me.did, "collection": COLLECTION, "rkey": RKEY} 28 + ) 29 + _handles = set(result.value.get("handles", [])) 30 + logger.info(f"loaded {len(_handles)} mentionable handles from PDS") 31 + except Exception: 32 + _handles = set() 33 + logger.info("no mentionable handles record on PDS yet") 34 + _loaded = True 35 + 36 + 37 + async def _save() -> None: 38 + await bot_client.authenticate() 39 + assert bot_client.client.me is not None 40 + bot_client.client.com.atproto.repo.put_record( 41 + data={ 42 + "repo": bot_client.client.me.did, 43 + "collection": COLLECTION, 44 + "rkey": RKEY, 45 + "record": { 46 + "$type": COLLECTION, 47 + "handles": sorted(_handles), 48 + "updatedAt": datetime.now(UTC).isoformat(), 49 + }, 50 + } 51 + ) 52 + 53 + 54 + async def get_mentionable_handles() -> set[str]: 55 + if not _loaded: 56 + await _load() 57 + return set(_handles) 58 + 59 + 60 + async def add_handle(handle: str) -> set[str]: 61 + if not _loaded: 62 + await _load() 63 + _handles.add(handle) 64 + await _save() 65 + logger.info(f"added {handle} to mentionable handles") 66 + return set(_handles) 67 + 68 + 69 + async def remove_handle(handle: str) -> set[str]: 70 + if not _loaded: 71 + await _load() 72 + _handles.discard(handle) 73 + await _save() 74 + logger.info(f"removed {handle} from mentionable handles") 75 + return set(_handles)
+14 -8
src/bot/services/message_handler.py
··· 26 26 _user_limit = parse_limit("30/hour") 27 27 28 28 29 - def _allowed_handles(*extra: str) -> set[str]: 29 + async def _allowed_handles(*extra: str) -> set[str]: 30 30 """Build the set of handles phi may tag (create mention facets for). 31 31 32 - Always includes the bot owner and the bot itself. Pass conversation 33 - participants as *extra*. 32 + Always includes the bot owner, the bot itself, and anyone who has 33 + opted in (stored on PDS). Pass conversation participants as *extra*. 34 34 """ 35 + from bot.core.mentionable import get_mentionable_handles 36 + 35 37 base = {settings.owner_handle, settings.bluesky_handle} 38 + try: 39 + base.update(await get_mentionable_handles()) 40 + except Exception: 41 + logger.warning("failed to load mentionable handles from PDS, using base set") 36 42 return base | {h for h in extra if h} 37 43 38 44 ··· 107 113 reply_ref = models.AppBskyFeedPost.ReplyRef( 108 114 parent=parent_ref, root=root_ref 109 115 ) 110 - allowed = _allowed_handles(author_handle) 116 + allowed = await _allowed_handles(author_handle) 111 117 await self.client.create_post( 112 118 response.text, reply_to=reply_ref, allowed_handles=allowed 113 119 ) ··· 137 143 logger.info(f"ignoring follow from @{author_handle}: {response.reason}") 138 144 elif response.action == "reply" and response.text: 139 145 # post as a top-level post since there's no thread to reply to 140 - allowed = _allowed_handles(author_handle) 146 + allowed = await _allowed_handles(author_handle) 141 147 await self.client.create_post(response.text, allowed_handles=allowed) 142 148 bot_status.record_response() 143 149 logger.info(f"posted on follow from @{author_handle}: {response.text[:80]}") ··· 222 228 reply_ref = models.AppBskyFeedPost.ReplyRef( 223 229 parent=parent_ref, root=root_ref 224 230 ) 225 - allowed = _allowed_handles(author_handle) 231 + allowed = await _allowed_handles(author_handle) 226 232 await self.client.create_post( 227 233 response.text, reply_to=reply_ref, allowed_handles=allowed 228 234 ) ··· 253 259 254 260 if response.action in ("reply", "post") and response.text: 255 261 try: 256 - allowed = _allowed_handles() 262 + allowed = await _allowed_handles() 257 263 await self.client.create_post( 258 264 response.text, allowed_handles=allowed 259 265 ) ··· 286 292 287 293 if response.action in ("reply", "post") and response.text: 288 294 try: 289 - allowed = _allowed_handles() 295 + allowed = await _allowed_handles() 290 296 await self.client.create_post( 291 297 response.text, allowed_handles=allowed 292 298 )
+36 -2
src/bot/tools/bluesky.py
··· 11 11 12 12 from bot.config import settings 13 13 from bot.core.atproto_client import bot_client 14 - from bot.tools._helpers import PhiDeps, _check_services_impl, _relative_age 14 + from bot.core.mentionable import add_handle, get_mentionable_handles, remove_handle 15 + from bot.tools._helpers import PhiDeps, _check_services_impl, _is_owner, _relative_age 15 16 16 17 17 18 def register(agent): ··· 19 20 async def post(ctx: RunContext[PhiDeps], text: str) -> str: 20 21 """Create a new top-level post on Bluesky (not a reply). Use this when you want to share something with your followers unprompted.""" 21 22 try: 22 - # top-level posts: only allow tagging owner + self 23 23 allowed = {settings.owner_handle, settings.bluesky_handle} 24 + allowed.update(await get_mentionable_handles()) 24 25 if ctx.deps.author_handle: 25 26 allowed.add(ctx.deps.author_handle) 26 27 await bot_client.create_post(text, allowed_handles=allowed) ··· 109 110 return "provide a label value to remove" 110 111 labels = remove_self_label(bot_client.client, label) 111 112 return f"removed '{label}', labels now: {labels}" 113 + else: 114 + return f"unknown action '{action}', use 'list', 'add', or 'remove'" 115 + 116 + @agent.tool 117 + async def manage_mentionable( 118 + ctx: RunContext[PhiDeps], action: str, handle: str = "" 119 + ) -> str: 120 + """Manage the list of people who have opted in to being @mentioned by you. 121 + OWNER-ONLY (restricted to @{settings.owner_handle}). 122 + 123 + Actions: 'list' to see who's opted in, 'add' to add a handle, 'remove' to remove one. 124 + 125 + When someone tells you "you can tag me" or similar, ask nate to confirm 126 + before adding them. Never add someone without operator approval.""" 127 + if not _is_owner(ctx): 128 + return f"only @{settings.owner_handle} can manage the mentionable list" 129 + if action == "list": 130 + handles = await get_mentionable_handles() 131 + if handles: 132 + return f"opted-in handles: {', '.join(sorted(handles))}" 133 + return "no one has opted in yet" 134 + elif action == "add": 135 + if not handle: 136 + return "provide a handle to add" 137 + handles = await add_handle(handle) 138 + return ( 139 + f"added @{handle} — opted-in list is now: {', '.join(sorted(handles))}" 140 + ) 141 + elif action == "remove": 142 + if not handle: 143 + return "provide a handle to remove" 144 + handles = await remove_handle(handle) 145 + return f"removed @{handle} — opted-in list is now: {', '.join(sorted(handles)) or '(empty)'}" 112 146 else: 113 147 return f"unknown action '{action}', use 'list', 'add', or 'remove'" 114 148