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:
@@ -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 = {
|
||||
|
||||
Reference in New Issue
Block a user