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 DotDict .get() bug across curiosity_queue, mentionable, and blog tools

atproto SDK's DotDict intercepts attribute access via __getattr__,
which means .get() resolves to DotDict["get"] (None for a missing key)
instead of dict.get(). Calling None("key") then raises:
TypeError: 'NoneType' object is not callable

This broke the entire exploration loop — every exploration window since
Apr 8 (6 consecutive failures over 3 days) failed with this exact error
on claim() at r.value.get("status"). The curiosity queue had a pending
item (mycelnet.bsky.social) that was never explored.

Also fixes:
- mentionable.py: latent — the mentionConsent record doesn't exist on
phi's PDS yet, so the .get() call was caught by the except block.
Would have failed the same way once someone opts in to mentions.
- blog.py: list_blog_posts used isinstance(val, dict) guards which
silently fell through to defaults (DotDict is not a dict subclass),
so all blog posts showed as "untitled" in listings. The title-
duplicate check also silently failed, comparing "" == title.

Fix: use bracket access (val["key"]) for required fields on DotDict
values. For blog.py, wrap with dict(rec.value) at point of use so
.get() with defaults works naturally for optional fields.

Found by actually trying to reproduce the exploration failure locally
instead of theorizing about it.

zzstoatzz 1dad5d62 28501c57

+23 -25
+13 -12
src/bot/core/curiosity_queue.py
··· 4 4 at://{did}/io.zzstoatzz.phi.curiosityQueue/{tid} 5 5 6 6 Lifecycle: pending → in_progress → completed | failed 7 + 8 + NOTE: record values from the atproto SDK are DotDict objects, NOT plain dicts. 9 + DotDict intercepts attribute access via __getattr__, which means .get() resolves 10 + to DotDict["get"] (None) instead of dict.get(). Always use bracket access 11 + (val["key"]) for record value fields, not .get(). 7 12 """ 8 13 9 14 import logging ··· 63 68 for rec in records: 64 69 val = rec.value 65 70 if ( 66 - val.get("kind") == kind 67 - and val.get("subject") == subject 68 - and val.get("status") in ("pending", "in_progress") 71 + val["kind"] == kind 72 + and val["subject"] == subject 73 + and val["status"] in ("pending", "in_progress") 69 74 ): 70 75 logger.debug(f"duplicate queue item: {kind} {subject}") 71 76 return False ··· 98 103 """ 99 104 records = await _list_records() 100 105 101 - pending = [r for r in records if r.value.get("status") == "pending"] 106 + pending = [r for r in records if r.value["status"] == "pending"] 102 107 if not pending: 103 108 return None 104 109 ··· 106 111 oldest = pending[-1] 107 112 value = await _update_status(oldest, "in_progress") 108 113 rkey = _rkey(oldest) 109 - logger.info(f"claimed: {value.get('kind')} {value.get('subject')}") 114 + logger.info(f"claimed: {value['kind']} {value['subject']}") 110 115 return value, rkey 111 116 112 117 ··· 116 121 for rec in records: 117 122 if _rkey(rec) == rkey: 118 123 await _update_status(rec, "completed") 119 - logger.info( 120 - f"completed: {rec.value.get('kind')} {rec.value.get('subject')}" 121 - ) 124 + logger.info(f"completed: {rec.value['kind']} {rec.value['subject']}") 122 125 return 123 126 124 127 ··· 128 131 for rec in records: 129 132 if _rkey(rec) == rkey: 130 133 await _update_status(rec, "failed") 131 - logger.warning( 132 - f"failed: {rec.value.get('kind')} {rec.value.get('subject')}" 133 - ) 134 + logger.warning(f"failed: {rec.value['kind']} {rec.value['subject']}") 134 135 return 135 136 136 137 137 138 async def list_pending(limit: int = 10) -> list[dict]: 138 139 """List pending queue items for inspection.""" 139 140 records = await _list_records() 140 - pending = [dict(r.value) for r in records if r.value.get("status") == "pending"] 141 + pending = [dict(r.value) for r in records if r.value["status"] == "pending"] 141 142 return pending[:limit]
+3 -1
src/bot/core/mentionable.py
··· 26 26 result = bot_client.client.com.atproto.repo.get_record( 27 27 {"repo": bot_client.client.me.did, "collection": COLLECTION, "rkey": RKEY} 28 28 ) 29 - _handles = set(result.value.get("handles", [])) 29 + # bracket access — result.value is a DotDict where .get() is 30 + # intercepted as attribute lookup and returns None 31 + _handles = set(result.value["handles"]) 30 32 logger.info(f"loaded {len(_handles)} mentionable handles from PDS") 31 33 except Exception: 32 34 _handles = set()
+7 -12
src/bot/tools/blog.py
··· 31 31 32 32 lines = [] 33 33 for rec in response.records: 34 - val = rec.value 35 - title = ( 36 - val.get("title", "untitled") 37 - if isinstance(val, dict) 38 - else "untitled" 39 - ) 34 + # dict() wraps DotDict → plain dict so .get() works normally 35 + val = dict(rec.value) 36 + title = val.get("title", "untitled") 40 37 rkey = rec.uri.split("/")[-1] 41 - published = val.get("publishedAt", "") if isinstance(val, dict) else "" 42 - tags = val.get("tags", []) if isinstance(val, dict) else [] 38 + published = val.get("publishedAt", "") 39 + tags = val.get("tags", []) 43 40 url = f"https://greengale.app/{handle}/{rkey}" 44 41 tag_str = f" [{', '.join(tags)}]" if tags else "" 45 42 date_str = f" ({published[:10]})" if published else "" ··· 93 90 ) 94 91 if existing.records: 95 92 for rec in existing.records: 96 - val = rec.value 97 - existing_title = ( 98 - val.get("title", "") if isinstance(val, dict) else "" 99 - ) 93 + val = dict(rec.value) 94 + existing_title = val.get("title", "") 100 95 if existing_title == title: 101 96 rkey = rec.uri.split("/")[-1] 102 97 return (