Implement working Mastodon bot with proposal system

Major Features:
- Mastodon integration with polling-based listener (streaming unreliable)
- Claude AI integration via llm CLI with API key support
- Public proposal announcements with voting
- Markdown stripping for Mastodon plain text
- Thread-aware voting system

Configuration:
- Added requirements.txt with all dependencies
- API key configuration in config.yaml (not streamed keys)
- Support for multiple Claude models via llm-anthropic

Platform Adapter (Mastodon):
- Polling notifications every 5 seconds (more reliable than streaming)
- Notification ID tracking to prevent re-processing on restart
- Markdown stripping for clean plain text output
- Vote thread matching via announcement IDs

Agent & Governance:
- Conversational tone (direct, concise, not legalistic)
- Proposal creation with AI-generated titles and descriptions
- Public announcements for proposals with all details
- Vote casting with automatic proposal detection from threads
- Constitutional reasoning for governance decisions

Bot Features:
- Long message splitting into threaded posts
- Public proposal announcements separate from user replies
- Announcement includes: title, proposer, description, deadline, voting instructions
- Vote tracking linked to proposal announcement threads

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
Nathan Schneider
2026-02-06 22:26:42 -07:00
parent b636a805f9
commit 5fe22060e1
6 changed files with 351 additions and 39 deletions

View File

@@ -135,6 +135,9 @@ class MastodonAdapter(PlatformAdapter):
"""Disconnect from Mastodon and cleanup."""
logger.info("Disconnecting from Mastodon")
# Stop polling
self.polling = False
# Stop stream listener if running
if self.stream_listener:
self.stream_listener.stop()
@@ -149,7 +152,7 @@ class MastodonAdapter(PlatformAdapter):
def start_listening(self, callback: Callable[[PlatformMessage], None]):
"""
Start listening for mentions via Mastodon streaming API.
Start listening for mentions via polling (streaming API can be unreliable).
Args:
callback: Function to call with each received message
@@ -157,27 +160,71 @@ class MastodonAdapter(PlatformAdapter):
if not self.connected or not self.client:
raise RuntimeError("Must call connect() before start_listening()")
logger.info("Starting Mastodon stream listener for mentions")
logger.info("Starting Mastodon notification poller")
# Create stream listener
self.stream_listener = GovbotStreamListener(
bot_id=self.bot_user_id,
callback=callback,
adapter=self,
)
# Track last seen notification ID to avoid duplicates
self.last_notification_id = None
self.callback = callback
self.polling = True
# Start streaming in a separate thread
def stream_thread():
# Get the current latest notification to set baseline
# This prevents re-processing old notifications on restart
try:
latest = self.client.notifications(limit=1)
if latest:
self.last_notification_id = latest[0]['id']
logger.info(f"Starting from notification ID: {self.last_notification_id}")
except Exception as e:
logger.warning(f"Could not get latest notification: {e}")
# Start polling in a separate thread
def poll_thread():
try:
# Stream user timeline (includes mentions)
self.client.stream_user(self.stream_listener, run_async=False, reconnect_async=True)
except Exception as e:
logger.error(f"Stream listener error: {e}", exc_info=True)
while self.polling:
try:
# Get recent notifications
notifications = self.client.notifications(
limit=20,
since_id=self.last_notification_id
)
self.listener_thread = threading.Thread(target=stream_thread, daemon=True)
# Process new notifications in reverse order (oldest first)
for notif in reversed(notifications):
# Only process mentions
if notif['type'] in ['mention']:
status = notif.get('status')
if not status:
continue
# Don't respond to ourselves
if str(status['account']['id']) == self.bot_user_id:
continue
# Update last seen ID
self.last_notification_id = notif['id']
# Convert to PlatformMessage
message = self._status_to_message(status)
message.mentions_bot = True
# Call the callback
logger.info(f"Processing mention from @{message.author_handle}")
self.callback(message)
# Sleep before next poll
time.sleep(5) # Poll every 5 seconds
except Exception as e:
logger.error(f"Error polling notifications: {e}", exc_info=True)
time.sleep(10) # Wait longer on error
except Exception as e:
logger.error(f"Poll thread error: {e}", exc_info=True)
self.listener_thread = threading.Thread(target=poll_thread, daemon=True)
self.listener_thread.start()
logger.info("Stream listener started")
logger.info("Notification poller started")
def post(
self,
@@ -190,7 +237,7 @@ class MastodonAdapter(PlatformAdapter):
Post a message to Mastodon.
Args:
message: Text content to post (max 500 characters for most instances)
message: Text content to post (Markdown will be stripped for plain text)
thread_id: Not used in Mastodon (use reply_to_id for threading)
reply_to_id: Status ID to reply to
visibility: Message visibility level
@@ -204,6 +251,9 @@ class MastodonAdapter(PlatformAdapter):
if not self.connected or not self.client:
raise RuntimeError("Must call connect() before posting")
# Strip Markdown formatting for Mastodon (plain text only)
message = self._strip_markdown(message)
# Map visibility to Mastodon format
mastodon_visibility = self._map_visibility(visibility)
@@ -446,6 +496,42 @@ class MastodonAdapter(PlatformAdapter):
return f"{self.instance_url}/web/statuses/{thread_id}"
# Private helper methods
def _status_to_message(self, status: Dict[str, Any]) -> PlatformMessage:
"""Convert Mastodon status to PlatformMessage"""
from html import unescape
import re
# Extract text content (strip HTML)
content = status.get("content", "")
# Simple HTML stripping
content = re.sub(r"<[^>]+>", "", content)
content = unescape(content)
# Map visibility
visibility_map = {
"public": MessageVisibility.PUBLIC,
"unlisted": MessageVisibility.UNLISTED,
"private": MessageVisibility.FOLLOWERS,
"direct": MessageVisibility.DIRECT,
}
visibility = visibility_map.get(
status.get("visibility", "public"), MessageVisibility.PUBLIC
)
return PlatformMessage(
id=str(status["id"]),
text=content,
author_id=str(status["account"]["id"]),
author_handle=status["account"]["username"],
timestamp=status["created_at"],
thread_id=str(status.get("in_reply_to_id", status["id"])),
reply_to_id=str(status["in_reply_to_id"]) if status.get("in_reply_to_id") else None,
visibility=visibility,
raw_data=status,
)
# Private helper methods for skill execution
def _suspend_account(self, params: Dict[str, Any]) -> Dict[str, Any]:
@@ -582,6 +668,28 @@ class MastodonAdapter(PlatformAdapter):
"reverse_params": {"announcement_id": announcement["id"]},
}
def _strip_markdown(self, text: str) -> str:
"""Strip Markdown formatting for plain text display on Mastodon"""
import re
# Remove bold/italic markers
text = re.sub(r'\*\*([^\*]+)\*\*', r'\1', text) # **bold** -> bold
text = re.sub(r'\*([^\*]+)\*', r'\1', text) # *italic* -> italic
text = re.sub(r'__([^_]+)__', r'\1', text) # __bold__ -> bold
text = re.sub(r'_([^_]+)_', r'\1', text) # _italic_ -> italic
text = re.sub(r'`([^`]+)`', r'\1', text) # `code` -> code
# Remove headers but keep the text
text = re.sub(r'^#{1,6}\s+', '', text, flags=re.MULTILINE)
# Convert Markdown lists to simple text with bullets
text = re.sub(r'^\s*[-*+]\s+', '', text, flags=re.MULTILINE)
# Remove link formatting but keep URLs: [text](url) -> text (url)
text = re.sub(r'\[([^\]]+)\]\(([^\)]+)\)', r'\1 (\2)', text)
return text
def _map_visibility(self, visibility: MessageVisibility) -> str:
"""Map abstract visibility to Mastodon visibility"""
mapping = {