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.

fix: gate mention facets behind consent allowlist

parse_mentions() now accepts allowed_handles — only handles in the set
get notification-sending mention facets. all other @handles render as
plain text (visible but silent). the allowlist is {owner, bot, current
conversation participant} at every create_post call site.

also adds mention consent guidance to phi's operational instructions
so the LLM avoids @mentioning third parties in the first place.

fixes the boris incident where phi auto-resolved a handle in its reply
text and tagged someone who never interacted with the bot.

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

+139 -14
+8 -1
src/bot/agent.py
··· 142 142 only use this during daily reflection, or when someone explicitly asks about infrastructure, services, or uptime of specific apps. 143 143 if something is down, post about it and tag @{settings.owner_handle}. 144 144 145 + mention consent: 146 + 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}. 147 + 145 148 IMPORTANT: never paginate through list_records repeatedly. if you need more data than one call returns, work with what you have. endless pagination wastes your request budget and produces no response. 146 149 """.strip() 147 150 ··· 633 636 async def post(ctx: RunContext[PhiDeps], text: str) -> str: 634 637 """Create a new top-level post on Bluesky (not a reply). Use this when you want to share something with your followers unprompted.""" 635 638 try: 636 - await bot_client.create_post(text) 639 + # top-level posts: only allow tagging owner + self 640 + allowed = {settings.owner_handle, settings.bluesky_handle} 641 + if ctx.deps.author_handle: 642 + allowed.add(ctx.deps.author_handle) 643 + await bot_client.create_post(text, allowed_handles=allowed) 637 644 return f"posted: {text[:100]}" 638 645 except Exception as e: 639 646 return f"failed to post: {e}"
+12 -4
src/bot/core/atproto_client.py
··· 141 141 # Use the params format instead of data 142 142 self.client.app.bsky.notification.update_seen({"seenAt": seen_at}) 143 143 144 - async def create_post(self, text: str, reply_to=None): 145 - """Create a new post or reply. Splits long text into a self-reply thread.""" 144 + async def create_post( 145 + self, text: str, reply_to=None, allowed_handles: set[str] | None = None 146 + ): 147 + """Create a new post or reply. Splits long text into a self-reply thread. 148 + 149 + *allowed_handles* controls which @mentions become notification-sending 150 + facets. Pass the set of handles who consented to interaction (e.g. 151 + the message author + the bot owner). ``None`` = no filtering (all 152 + mentions become facets, legacy behaviour for the ``post`` tool). 153 + """ 146 154 await self.authenticate() 147 155 148 156 if len(text) <= 300: 149 - facets = create_facets(text, self.client) 157 + facets = create_facets(text, self.client, allowed_handles) 150 158 if reply_to: 151 159 return self.client.send_post( 152 160 text=text, reply_to=reply_to, facets=facets ··· 158 166 last_result = None 159 167 160 168 for i, chunk in enumerate(chunks): 161 - facets = create_facets(chunk, self.client) 169 + facets = create_facets(chunk, self.client, allowed_handles) 162 170 163 171 if i == 0: 164 172 last_result = self.client.send_post(
+23 -5
src/bot/core/rich_text.py
··· 10 10 BARE_URL_REGEX = rb"(?:^|[$|\W])((?:[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,}(?:/[-a-zA-Z0-9@:%_\+.~#?&//=]*)?)" 11 11 12 12 13 - def parse_mentions(text: str, client: Client) -> list[dict[str, Any]]: 14 - """Parse @mentions and create facets with proper byte positions""" 13 + def parse_mentions( 14 + text: str, client: Client, allowed_handles: set[str] | None = None 15 + ) -> list[dict[str, Any]]: 16 + """Parse @mentions and create facets with proper byte positions. 17 + 18 + If *allowed_handles* is provided, only handles in the set get a mention 19 + facet (which sends a notification). All other @handles are left as plain 20 + text — visible but silent. 21 + """ 15 22 facets = [] 16 23 text_bytes = text.encode("UTF-8") 17 24 18 25 for match in re.finditer(MENTION_REGEX, text_bytes): 19 26 handle = match.group(1)[1:].decode("UTF-8") # Remove @ prefix 27 + 28 + # consent gate: skip handles not in the allowlist 29 + if allowed_handles is not None and handle not in allowed_handles: 30 + continue 31 + 20 32 mention_start = match.start(1) 21 33 mention_end = match.end(1) 22 34 ··· 93 105 return facets 94 106 95 107 96 - def create_facets(text: str, client: Client) -> list[dict[str, Any]]: 97 - """Create all facets for a post (mentions and URLs)""" 108 + def create_facets( 109 + text: str, client: Client, allowed_handles: set[str] | None = None 110 + ) -> list[dict[str, Any]]: 111 + """Create all facets for a post (mentions and URLs). 112 + 113 + *allowed_handles* gates which @mentions become notification-sending facets. 114 + See :func:`parse_mentions` for details. 115 + """ 98 116 facets = [] 99 - facets.extend(parse_mentions(text, client)) 117 + facets.extend(parse_mentions(text, client, allowed_handles)) 100 118 facets.extend(parse_urls(text)) 101 119 return facets
+25 -4
src/bot/services/message_handler.py
··· 9 9 from limits.strategies import MovingWindowRateLimiter 10 10 11 11 from bot.agent import PhiAgent 12 + from bot.config import settings 12 13 from bot.core.atproto_client import BotClient 13 14 from bot.status import bot_status 14 15 from bot.utils.thread import ( ··· 23 24 _storage = MemoryStorage() 24 25 _limiter = MovingWindowRateLimiter(_storage) 25 26 _user_limit = parse_limit("30/hour") 27 + 28 + 29 + def _allowed_handles(*extra: str) -> set[str]: 30 + """Build the set of handles phi may tag (create mention facets for). 31 + 32 + Always includes the bot owner and the bot itself. Pass conversation 33 + participants as *extra*. 34 + """ 35 + base = {settings.owner_handle, settings.bluesky_handle} 36 + return base | {h for h in extra if h} 26 37 27 38 28 39 class MessageHandler: ··· 96 107 reply_ref = models.AppBskyFeedPost.ReplyRef( 97 108 parent=parent_ref, root=root_ref 98 109 ) 99 - await self.client.create_post(response.text, reply_to=reply_ref) 110 + allowed = _allowed_handles(author_handle) 111 + await self.client.create_post( 112 + response.text, reply_to=reply_ref, allowed_handles=allowed 113 + ) 100 114 bot_status.record_response() 101 115 logger.info( 102 116 f"replied on {reason} from @{author_handle}: {response.text[:80]}" ··· 123 137 logger.info(f"ignoring follow from @{author_handle}: {response.reason}") 124 138 elif response.action == "reply" and response.text: 125 139 # post as a top-level post since there's no thread to reply to 126 - await self.client.create_post(response.text) 140 + allowed = _allowed_handles(author_handle) 141 + await self.client.create_post(response.text, allowed_handles=allowed) 127 142 bot_status.record_response() 128 143 logger.info(f"posted on follow from @{author_handle}: {response.text[:80]}") 129 144 else: ··· 207 222 reply_ref = models.AppBskyFeedPost.ReplyRef( 208 223 parent=parent_ref, root=root_ref 209 224 ) 210 - await self.client.create_post(response.text, reply_to=reply_ref) 225 + allowed = _allowed_handles(author_handle) 226 + await self.client.create_post( 227 + response.text, reply_to=reply_ref, allowed_handles=allowed 228 + ) 211 229 212 230 bot_status.record_response() 213 231 logger.info(f"replied to @{author_handle}: {response.text[:80]}") ··· 234 252 235 253 if response.action in ("reply", "post") and response.text: 236 254 try: 237 - await self.client.create_post(response.text) 255 + allowed = _allowed_handles() 256 + await self.client.create_post( 257 + response.text, allowed_handles=allowed 258 + ) 238 259 bot_status.record_response() 239 260 logger.info(f"daily reflection posted: {response.text[:80]}") 240 261 except Exception as e:
+71
tests/test_mention_allowlist.py
··· 1 + """Regression test: mention facets respect the consent allowlist. 2 + 3 + The allowlist ensures phi only sends notifications to people who consented 4 + to interact — the conversation participant, the bot owner, and the bot itself. 5 + Third-party handles appear as plain text, not clickable mentions. 6 + """ 7 + 8 + from types import SimpleNamespace 9 + 10 + from bot.core.rich_text import parse_mentions 11 + 12 + 13 + def _mock_resolve(handle: str): 14 + """Fake DID resolution — returns a predictable DID for any handle.""" 15 + return SimpleNamespace(did=f"did:plc:fake-{handle.replace('.', '-')}") 16 + 17 + 18 + def _make_client(): 19 + """Minimal mock client with resolve_handle.""" 20 + client = SimpleNamespace() 21 + client.com = SimpleNamespace() 22 + client.com.atproto = SimpleNamespace() 23 + client.com.atproto.identity = SimpleNamespace() 24 + client.com.atproto.identity.resolve_handle = lambda params: _mock_resolve( 25 + params["handle"] 26 + ) 27 + return client 28 + 29 + 30 + def test_allowlist_filters_third_party(): 31 + """Handles not in the allowlist should NOT get mention facets.""" 32 + text = "interesting point by @stranger.bsky.social and @friend.bsky.social" 33 + client = _make_client() 34 + 35 + facets = parse_mentions(text, client, allowed_handles={"friend.bsky.social"}) 36 + 37 + assert len(facets) == 1 38 + assert facets[0]["features"][0]["did"] == "did:plc:fake-friend-bsky-social" 39 + 40 + 41 + def test_allowlist_none_allows_all(): 42 + """When allowlist is None (legacy), all handles get facets.""" 43 + text = "@alice.bsky.social and @bob.bsky.social" 44 + client = _make_client() 45 + 46 + facets = parse_mentions(text, client, allowed_handles=None) 47 + 48 + assert len(facets) == 2 49 + 50 + 51 + def test_allowlist_empty_blocks_all(): 52 + """Empty allowlist means no one gets tagged.""" 53 + text = "@alice.bsky.social" 54 + client = _make_client() 55 + 56 + facets = parse_mentions(text, client, allowed_handles=set()) 57 + 58 + assert len(facets) == 0 59 + 60 + 61 + def test_allowlist_preserves_byte_positions(): 62 + """Allowed mentions should still have correct byte offsets.""" 63 + text = "hey @owner.handle check this" 64 + client = _make_client() 65 + 66 + facets = parse_mentions(text, client, allowed_handles={"owner.handle"}) 67 + 68 + assert len(facets) == 1 69 + start = facets[0]["index"]["byteStart"] 70 + end = facets[0]["index"]["byteEnd"] 71 + assert text.encode("UTF-8")[start:end] == b"@owner.handle"