""" Mastodon platform adapter for Govbot. Implements the platform interface for Mastodon/Fediverse instances, enabling governance bots to work on any Mastodon-compatible server. Features: --------- - Streaming API for real-time mentions - Thread-aware posting - Admin and moderation skills - Visibility controls (public, unlisted, followers-only, direct) - Instance-level and user-level actions Configuration Required: ----------------------- - instance_url: Your Mastodon instance URL - access_token: Bot account access token - bot_username: Bot's username (for filtering mentions) Optional: - client_id: OAuth client ID (for token generation) - client_secret: OAuth client secret Getting Access Tokens: ---------------------- 1. Register application at: https://your-instance/settings/applications/new 2. Generate access token with appropriate scopes: - read (for streaming) - write (for posting) - admin:read, admin:write (for instance management, if bot is admin) See PLATFORMS.md for detailed setup guide. """ import logging from typing import Callable, Optional, Dict, Any, List from datetime import datetime import time import threading try: from mastodon import Mastodon, StreamListener, MastodonError MASTODON_AVAILABLE = True except ImportError: MASTODON_AVAILABLE = False logging.warning("Mastodon.py not installed. Install with: pip install Mastodon.py") from .base import ( PlatformAdapter, PlatformMessage, PlatformSkill, SkillParameter, MessageVisibility, ) logger = logging.getLogger("govbot.platforms.mastodon") class MastodonAdapter(PlatformAdapter): """ Mastodon platform adapter implementation. Connects to Mastodon instances and provides governance capabilities. """ def __init__(self, config: Dict[str, Any]): """ Initialize Mastodon adapter. Args: config: Configuration dictionary with: - instance_url: Mastodon instance URL - access_token: Bot access token - bot_username: Bot's username - client_id (optional): OAuth client ID - client_secret (optional): OAuth client secret """ super().__init__(config) if not MASTODON_AVAILABLE: raise ImportError( "Mastodon.py is required for Mastodon adapter. " "Install with: pip install Mastodon.py" ) self.instance_url = config.get("instance_url") self.access_token = config.get("access_token") self.bot_username = config.get("bot_username", "govbot") if not self.instance_url or not self.access_token: raise ValueError( "Mastodon adapter requires 'instance_url' and 'access_token' in config" ) self.client: Optional[Mastodon] = None self.stream_listener: Optional['GovbotStreamListener'] = None self.listener_thread: Optional[threading.Thread] = None def connect(self) -> bool: """ Connect to Mastodon instance. Returns: True if connection successful Raises: MastodonError: If connection fails """ try: logger.info(f"Connecting to Mastodon instance: {self.instance_url}") self.client = Mastodon( access_token=self.access_token, api_base_url=self.instance_url, ) # Verify credentials and get bot account info account = self.client.account_verify_credentials() self.bot_user_id = str(account["id"]) self.bot_username = account["username"] logger.info( f"Connected as @{self.bot_username} (ID: {self.bot_user_id})" ) self.connected = True return True except Exception as e: logger.error(f"Failed to connect to Mastodon: {e}") raise def disconnect(self): """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() self.stream_listener = None if self.listener_thread: self.listener_thread.join(timeout=5) self.listener_thread = None self.connected = False logger.info("Disconnected from Mastodon") def start_listening(self, callback: Callable[[PlatformMessage], None]): """ Start listening for mentions via polling (streaming API can be unreliable). Args: callback: Function to call with each received message """ if not self.connected or not self.client: raise RuntimeError("Must call connect() before start_listening()") logger.info("Starting Mastodon notification poller") # Track last seen notification ID to avoid duplicates self.last_notification_id = None self.callback = callback self.polling = True # 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: while self.polling: try: # Get recent notifications notifications = self.client.notifications( limit=20, since_id=self.last_notification_id ) # 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("Notification poller started") def post( self, message: str, thread_id: Optional[str] = None, reply_to_id: Optional[str] = None, visibility: MessageVisibility = MessageVisibility.PUBLIC, ) -> str: """ Post a message to Mastodon. Args: 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 Returns: Status ID of posted message Raises: MastodonError: If posting fails """ 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) try: status = self.client.status_post( status=message, in_reply_to_id=reply_to_id, visibility=mastodon_visibility, ) logger.info(f"Posted status {status['id']}") return str(status["id"]) except Exception as e: logger.error(f"Failed to post status: {e}") raise def get_skills(self) -> List[PlatformSkill]: """ Get Mastodon-specific skills. Returns: List of available Mastodon governance skills """ return [ # ===== ACCOUNT MODERATION ===== PlatformSkill( name="suspend_account", description="Suspend a user account (reversible, blocks login and hides content)", category="account_moderation", parameters=[ SkillParameter("account_id", "str", "Account ID to suspend"), SkillParameter("reason", "str", "Reason for suspension"), ], requires_confirmation=True, reversible=True, constitutional_authorization="Requires moderation authority per constitution", ), PlatformSkill( name="unsuspend_account", description="Lift suspension from an account (reverses suspension)", category="account_moderation", parameters=[ SkillParameter("account_id", "str", "Account ID to unsuspend"), ], requires_confirmation=True, reversible=True, constitutional_authorization="Requires moderation authority per constitution", ), PlatformSkill( name="silence_account", description="Silence a user account (hide from public timelines, reversible)", category="account_moderation", parameters=[ SkillParameter("account_id", "str", "Account ID to silence"), SkillParameter("reason", "str", "Reason for silencing"), ], requires_confirmation=True, reversible=True, constitutional_authorization="Requires moderation authority per constitution", ), PlatformSkill( name="unsilence_account", description="Lift silence from an account (reverses silencing)", category="account_moderation", parameters=[ SkillParameter("account_id", "str", "Account ID to unsilence"), ], requires_confirmation=True, reversible=True, constitutional_authorization="Requires moderation authority per constitution", ), PlatformSkill( name="disable_account", description="Disable local account login (reversible)", category="account_moderation", parameters=[ SkillParameter("account_id", "str", "Account ID to disable"), SkillParameter("reason", "str", "Reason for disabling"), ], requires_confirmation=True, reversible=True, constitutional_authorization="Requires moderation authority per constitution", ), PlatformSkill( name="enable_account", description="Re-enable a disabled local account", category="account_moderation", parameters=[ SkillParameter("account_id", "str", "Account ID to enable"), ], requires_confirmation=True, reversible=True, constitutional_authorization="Requires moderation authority per constitution", ), PlatformSkill( name="mark_account_sensitive", description="Mark account's media as always sensitive", category="account_moderation", parameters=[ SkillParameter("account_id", "str", "Account ID to mark"), SkillParameter("reason", "str", "Reason for marking sensitive"), ], requires_confirmation=True, reversible=True, constitutional_authorization="Requires moderation authority per constitution", ), PlatformSkill( name="unmark_account_sensitive", description="Remove sensitive flag from account", category="account_moderation", parameters=[ SkillParameter("account_id", "str", "Account ID to unmark"), ], requires_confirmation=True, reversible=True, constitutional_authorization="Requires moderation authority per constitution", ), PlatformSkill( name="delete_status", description="Delete a status/post (permanent)", category="account_moderation", parameters=[ SkillParameter("status_id", "str", "Status ID to delete"), SkillParameter("reason", "str", "Reason for deletion"), ], requires_confirmation=True, reversible=False, constitutional_authorization="Requires moderation authority per constitution", ), # ===== ACCOUNT MANAGEMENT ===== PlatformSkill( name="approve_account", description="Approve a pending account registration", category="account_management", parameters=[ SkillParameter("account_id", "str", "Account ID to approve"), ], requires_confirmation=False, reversible=False, constitutional_authorization="Requires account approval authority per constitution", ), PlatformSkill( name="reject_account", description="Reject a pending account registration (permanent)", category="account_management", parameters=[ SkillParameter("account_id", "str", "Account ID to reject"), ], requires_confirmation=True, reversible=False, constitutional_authorization="Requires account approval authority per constitution", ), PlatformSkill( name="delete_account_data", description="Permanently delete all data for a suspended account (IRREVERSIBLE)", category="account_management", parameters=[ SkillParameter("account_id", "str", "Suspended account ID to delete"), ], requires_confirmation=True, reversible=False, constitutional_authorization="Requires highest level authority per constitution", ), PlatformSkill( name="create_account", description="Create a new user account (requires email verification, may need approval)", category="account_management", parameters=[ SkillParameter("username", "str", "Desired username"), SkillParameter("email", "str", "Email address"), SkillParameter("password", "str", "Account password"), SkillParameter("reason", "str", "Registration reason (if approval required)", required=False), ], requires_confirmation=True, reversible=False, constitutional_authorization="Requires account creation authority per constitution", ), # ===== REPORT MANAGEMENT ===== PlatformSkill( name="assign_report", description="Assign a report to yourself for handling", category="reports", parameters=[ SkillParameter("report_id", "str", "Report ID to assign"), ], requires_confirmation=False, reversible=True, constitutional_authorization="Requires report management authority per constitution", ), PlatformSkill( name="unassign_report", description="Unassign a report so others can claim it", category="reports", parameters=[ SkillParameter("report_id", "str", "Report ID to unassign"), ], requires_confirmation=False, reversible=True, constitutional_authorization="Requires report management authority per constitution", ), PlatformSkill( name="resolve_report", description="Mark a report as resolved", category="reports", parameters=[ SkillParameter("report_id", "str", "Report ID to resolve"), ], requires_confirmation=False, reversible=True, constitutional_authorization="Requires report management authority per constitution", ), PlatformSkill( name="reopen_report", description="Reopen a closed report", category="reports", parameters=[ SkillParameter("report_id", "str", "Report ID to reopen"), ], requires_confirmation=False, reversible=True, constitutional_authorization="Requires report management authority per constitution", ), # ===== FEDERATION MANAGEMENT ===== PlatformSkill( name="block_domain", description="Block federation with a domain", category="federation", parameters=[ SkillParameter("domain", "str", "Domain to block"), SkillParameter("severity", "str", "Block severity: silence, suspend, or noop"), SkillParameter("public_comment", "str", "Public reason for block"), SkillParameter("private_comment", "str", "Internal note", required=False), SkillParameter("reject_media", "bool", "Reject media files from domain", required=False), SkillParameter("reject_reports", "bool", "Reject reports from domain", required=False), SkillParameter("obfuscate", "bool", "Hide domain name publicly", required=False), ], requires_confirmation=True, reversible=True, constitutional_authorization="Requires federation management authority per constitution", ), PlatformSkill( name="unblock_domain", description="Remove domain from block list", category="federation", parameters=[ SkillParameter("block_id", "str", "Domain block ID to remove"), ], requires_confirmation=True, reversible=True, constitutional_authorization="Requires federation management authority per constitution", ), PlatformSkill( name="allow_domain", description="Add domain to allowlist (for LIMITED_FEDERATION_MODE)", category="federation", parameters=[ SkillParameter("domain", "str", "Domain to allow"), ], requires_confirmation=True, reversible=True, constitutional_authorization="Requires federation management authority per constitution", ), PlatformSkill( name="disallow_domain", description="Remove domain from allowlist", category="federation", parameters=[ SkillParameter("allow_id", "str", "Domain allow ID to remove"), ], requires_confirmation=True, reversible=True, constitutional_authorization="Requires federation management authority per constitution", ), # ===== SECURITY MANAGEMENT ===== PlatformSkill( name="block_ip", description="Block IP address or range", category="security", parameters=[ SkillParameter("ip", "str", "IP address with CIDR prefix (e.g. 192.168.0.1/24)"), SkillParameter("severity", "str", "Block severity: sign_up_requires_approval, sign_up_block, or no_access"), SkillParameter("comment", "str", "Reason for IP block"), SkillParameter("expires_in", "int", "Expiration time in seconds", required=False), ], requires_confirmation=True, reversible=True, constitutional_authorization="Requires security management authority per constitution", ), PlatformSkill( name="unblock_ip", description="Remove IP block", category="security", parameters=[ SkillParameter("block_id", "str", "IP block ID to remove"), ], requires_confirmation=True, reversible=True, constitutional_authorization="Requires security management authority per constitution", ), PlatformSkill( name="block_email_domain", description="Block email domain from registrations", category="security", parameters=[ SkillParameter("domain", "str", "Email domain to block (e.g. spam.com)"), ], requires_confirmation=True, reversible=True, constitutional_authorization="Requires security management authority per constitution", ), PlatformSkill( name="unblock_email_domain", description="Remove email domain from block list", category="security", parameters=[ SkillParameter("block_id", "str", "Email domain block ID to remove"), ], requires_confirmation=True, reversible=True, constitutional_authorization="Requires security management authority per constitution", ), # ===== INSTANCE ADMINISTRATION (LIMITED) ===== # Note: Role management is NOT available via Mastodon API # These are documented as unavailable for transparency # ===== CONSTITUTION MANAGEMENT ===== PlatformSkill( name="publish_constitution", description="Post constitution as pinned thread (deprecates previous version)", category="constitution", parameters=[ SkillParameter("constitution_text", "str", "Full constitution text in markdown"), SkillParameter("change_summary", "str", "Summary of what changed", required=False), ], requires_confirmation=True, reversible=False, constitutional_authorization="Requires constitutional amendment process", ), PlatformSkill( name="update_profile", description="Update bot profile information (bio, fields, display name)", category="profile", parameters=[ SkillParameter("display_name", "str", "Display name", required=False), SkillParameter("note", "str", "Bio/description", required=False), SkillParameter("fields", "list", "Profile fields (max 4, each with 'name' and 'value')", required=False), ], requires_confirmation=True, reversible=True, constitutional_authorization="Requires governance approval", ), # ===== WEB-ONLY SKILLS (NOT AVAILABLE VIA API) ===== PlatformSkill( name="update_instance_rules", description="[WEB-ONLY] Update instance rules - must be done through admin interface", category="admin_web_only", parameters=[ SkillParameter("rules", "list", "List of rule texts"), ], requires_confirmation=True, reversible=True, constitutional_authorization="Requires constitutional amendment process", ), PlatformSkill( name="create_announcement", description="[WEB-ONLY] Create announcement - must be done through admin interface", category="admin_web_only", parameters=[ SkillParameter("text", "str", "Announcement text"), SkillParameter("starts_at", "datetime", "When announcement starts", required=False), SkillParameter("ends_at", "datetime", "When announcement ends", required=False), ], requires_confirmation=False, reversible=True, constitutional_authorization="Authorized communicators per constitution", ), ] def get_platform_limitations(self) -> Dict[str, Any]: """ Get information about platform limitations and unavailable features. Returns: Dictionary describing what is and isn't possible via API """ return { "available_via_api": { "account_moderation": [ "suspend/unsuspend accounts", "silence/unsilence accounts", "disable/enable account login", "mark accounts as sensitive", "delete individual posts", ], "account_management": [ "approve/reject pending registrations", "delete suspended account data", "create new accounts (with email verification required)", ], "report_management": [ "assign/unassign reports", "resolve/reopen reports", "view report details", ], "federation": [ "block/unblock domains", "modify domain block settings", "manage domain allowlist", ], "security": [ "block/unblock IP addresses", "block/unblock email domains", ], }, "web_interface_only": { "instance_rules": "Creating and editing server rules must be done through the web admin interface at /admin/server_settings/rules", "announcements": "Creating instance-wide announcements must be done through the web admin interface at /admin/announcements", "roles": "ALL role management (including moderator, admin, and custom roles) must be done through the web admin interface at /admin/roles", "instance_settings": "Modifying instance settings (description, contact info, etc.) requires web admin access", }, "not_possible_via_api": { "role_management": "The Mastodon API does NOT support granting or revoking ANY roles (moderator, admin, or custom). Role management must be done through: (1) Web admin interface at /admin/roles, or (2) Command line using 'tootctl accounts modify username --role RoleName'", "admin_account_creation": "Admin accounts cannot be created via API - they must be created via command line using 'tootctl accounts create --role Owner'", }, "limitations": { "account_creation": "New accounts require email verification and may require manual approval depending on instance settings", "permissions_required": "All admin actions require both OAuth scopes AND appropriate role permissions (Manage Users, Manage Reports, etc.)", "rate_limits": "API endpoints are subject to rate limiting", "suspended_account_deletion": "Account data can only be deleted for already-suspended accounts", }, } def execute_skill( self, skill_name: str, parameters: Dict[str, Any], actor: str ) -> Dict[str, Any]: """ Execute a Mastodon-specific skill. Args: skill_name: Name of skill to execute parameters: Skill parameters actor: Who is requesting this action Returns: Execution result dictionary Raises: ValueError: If skill unknown or parameters invalid MastodonError: If execution fails """ if not self.connected or not self.client: raise RuntimeError("Must call connect() before executing skills") # Validate skill and parameters is_valid, error = self.validate_skill_execution(skill_name, parameters) if not is_valid: raise ValueError(error) logger.info(f"Executing skill '{skill_name}' requested by {actor}") # Route to appropriate handler try: # Account moderation if skill_name == "suspend_account": return self._suspend_account(parameters) elif skill_name == "unsuspend_account": return self._unsuspend_account(parameters) elif skill_name == "silence_account": return self._silence_account(parameters) elif skill_name == "unsilence_account": return self._unsilence_account(parameters) elif skill_name == "disable_account": return self._disable_account(parameters) elif skill_name == "enable_account": return self._enable_account(parameters) elif skill_name == "mark_account_sensitive": return self._mark_account_sensitive(parameters) elif skill_name == "unmark_account_sensitive": return self._unmark_account_sensitive(parameters) elif skill_name == "delete_status": return self._delete_status(parameters) # Account management elif skill_name == "approve_account": return self._approve_account(parameters) elif skill_name == "reject_account": return self._reject_account(parameters) elif skill_name == "delete_account_data": return self._delete_account_data(parameters) elif skill_name == "create_account": return self._create_account(parameters) # Report management elif skill_name == "assign_report": return self._assign_report(parameters) elif skill_name == "unassign_report": return self._unassign_report(parameters) elif skill_name == "resolve_report": return self._resolve_report(parameters) elif skill_name == "reopen_report": return self._reopen_report(parameters) # Federation management elif skill_name == "block_domain": return self._block_domain(parameters) elif skill_name == "unblock_domain": return self._unblock_domain(parameters) elif skill_name == "allow_domain": return self._allow_domain(parameters) elif skill_name == "disallow_domain": return self._disallow_domain(parameters) # Security management elif skill_name == "block_ip": return self._block_ip(parameters) elif skill_name == "unblock_ip": return self._unblock_ip(parameters) elif skill_name == "block_email_domain": return self._block_email_domain(parameters) elif skill_name == "unblock_email_domain": return self._unblock_email_domain(parameters) # Constitution management elif skill_name == "publish_constitution": return self._publish_constitution(parameters) elif skill_name == "update_profile": return self._update_profile(parameters) # Web-only skills (return helpful message) elif skill_name == "update_instance_rules": return self._update_instance_rules(parameters) elif skill_name == "create_announcement": return self._create_announcement(parameters) else: raise ValueError(f"Unknown skill: {skill_name}") except Exception as e: logger.error(f"Skill execution failed: {e}") return { "success": False, "message": f"Execution failed: {str(e)}", "data": {}, "reversible": False, } def get_user_info(self, user_id: str) -> Optional[Dict[str, Any]]: """ Get information about a Mastodon user. Args: user_id: Mastodon account ID Returns: User info dictionary or None if not found """ if not self.connected or not self.client: return None try: account = self.client.account(user_id) # Determine roles (requires admin API access) roles = ["member"] try: # Check if account is admin/moderator (requires admin scope) admin_account = self.client.admin_account(user_id) if admin_account.get("role", {}).get("name") == "Admin": roles.append("admin") elif admin_account.get("role", {}).get("name") == "Moderator": roles.append("moderator") except: # If we don't have admin access, can't check roles pass return { "id": str(account["id"]), "handle": account["username"], "display_name": account["display_name"], "roles": roles, "is_bot": account.get("bot", False), } except Exception as e: logger.error(f"Failed to get user info for {user_id}: {e}") return None def format_thread_url(self, thread_id: str) -> str: """ Generate URL to a Mastodon thread. Args: thread_id: Status ID Returns: Full URL to the status """ # Get the status to find its URL try: if self.client: status = self.client.status(thread_id) return status.get("url", f"{self.instance_url}/web/statuses/{thread_id}") except: pass 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 # ===== ACCOUNT MODERATION IMPLEMENTATIONS ===== def _suspend_account(self, params: Dict[str, Any]) -> Dict[str, Any]: """Suspend a user account""" account_id = params["account_id"] reason = params.get("reason", "Suspended by governance decision") self.client.admin_account_moderate( account_id, action="suspend", report_note=reason, ) return { "success": True, "message": f"Account {account_id} suspended", "data": {"account_id": account_id, "reason": reason}, "reversible": True, "reverse_skill": "unsuspend_account", } def _unsuspend_account(self, params: Dict[str, Any]) -> Dict[str, Any]: """Lift suspension from account""" account_id = params["account_id"] self.client.admin_account_unsuspend(account_id) return { "success": True, "message": f"Account {account_id} unsuspended", "data": {"account_id": account_id}, "reversible": True, "reverse_skill": "suspend_account", } def _silence_account(self, params: Dict[str, Any]) -> Dict[str, Any]: """Silence a user account""" account_id = params["account_id"] reason = params.get("reason", "Silenced by governance decision") self.client.admin_account_moderate( account_id, action="silence", report_note=reason, ) return { "success": True, "message": f"Account {account_id} silenced", "data": {"account_id": account_id, "reason": reason}, "reversible": True, "reverse_skill": "unsilence_account", } def _unsilence_account(self, params: Dict[str, Any]) -> Dict[str, Any]: """Lift silence from account""" account_id = params["account_id"] self.client.admin_account_unsilence(account_id) return { "success": True, "message": f"Account {account_id} unsilenced", "data": {"account_id": account_id}, "reversible": True, "reverse_skill": "silence_account", } def _disable_account(self, params: Dict[str, Any]) -> Dict[str, Any]: """Disable account login""" account_id = params["account_id"] reason = params.get("reason", "Disabled by governance decision") self.client.admin_account_moderate( account_id, action="disable", report_note=reason, ) return { "success": True, "message": f"Account {account_id} login disabled", "data": {"account_id": account_id, "reason": reason}, "reversible": True, "reverse_skill": "enable_account", } def _enable_account(self, params: Dict[str, Any]) -> Dict[str, Any]: """Re-enable account login""" account_id = params["account_id"] self.client.admin_account_enable(account_id) return { "success": True, "message": f"Account {account_id} login enabled", "data": {"account_id": account_id}, "reversible": True, "reverse_skill": "disable_account", } def _mark_account_sensitive(self, params: Dict[str, Any]) -> Dict[str, Any]: """Mark account media as sensitive""" account_id = params["account_id"] reason = params.get("reason", "Marked sensitive by governance decision") self.client.admin_account_moderate( account_id, action="sensitive", report_note=reason, ) return { "success": True, "message": f"Account {account_id} marked as sensitive", "data": {"account_id": account_id, "reason": reason}, "reversible": True, "reverse_skill": "unmark_account_sensitive", } def _unmark_account_sensitive(self, params: Dict[str, Any]) -> Dict[str, Any]: """Remove sensitive flag from account""" account_id = params["account_id"] self.client.admin_account_unsensitive(account_id) return { "success": True, "message": f"Account {account_id} no longer marked sensitive", "data": {"account_id": account_id}, "reversible": True, "reverse_skill": "mark_account_sensitive", } def _delete_status(self, params: Dict[str, Any]) -> Dict[str, Any]: """Delete a status""" status_id = params["status_id"] reason = params.get("reason", "Deleted by governance decision") self.client.status_delete(status_id) return { "success": True, "message": f"Status {status_id} deleted", "data": {"status_id": status_id, "reason": reason}, "reversible": False, } # ===== ACCOUNT MANAGEMENT IMPLEMENTATIONS ===== def _approve_account(self, params: Dict[str, Any]) -> Dict[str, Any]: """Approve pending account""" account_id = params["account_id"] self.client.admin_account_approve(account_id) return { "success": True, "message": f"Account {account_id} approved", "data": {"account_id": account_id}, "reversible": False, } def _reject_account(self, params: Dict[str, Any]) -> Dict[str, Any]: """Reject pending account""" account_id = params["account_id"] self.client.admin_account_reject(account_id) return { "success": True, "message": f"Account {account_id} rejected", "data": {"account_id": account_id}, "reversible": False, } def _delete_account_data(self, params: Dict[str, Any]) -> Dict[str, Any]: """Permanently delete suspended account data""" account_id = params["account_id"] # This only works on already-suspended accounts self.client.admin_account_delete(account_id) return { "success": True, "message": f"Account {account_id} data permanently deleted", "data": {"account_id": account_id}, "reversible": False, } def _create_account(self, params: Dict[str, Any]) -> Dict[str, Any]: """Create a new user account""" username = params["username"] email = params["email"] password = params["password"] reason = params.get("reason", "") # Note: This uses the public registration endpoint, not an admin endpoint # Requires registration to be enabled on the instance result = self.client.create_account( username=username, password=password, email=email, agreement=True, # Agrees to terms locale="en", reason=reason if reason else None, ) return { "success": True, "message": ( f"Account @{username} created. " f"User must verify email before login. " f"{'Manual approval may be required.' if reason else ''}" ), "data": { "username": username, "email": email, "account_id": str(result.get("id", "")), "requires_email_verification": True, "may_require_approval": bool(reason), }, "reversible": False, } # ===== REPORT MANAGEMENT IMPLEMENTATIONS ===== def _assign_report(self, params: Dict[str, Any]) -> Dict[str, Any]: """Assign report to self""" report_id = params["report_id"] self.client.admin_report_assign(report_id) return { "success": True, "message": f"Report {report_id} assigned to you", "data": {"report_id": report_id}, "reversible": True, "reverse_skill": "unassign_report", } def _unassign_report(self, params: Dict[str, Any]) -> Dict[str, Any]: """Unassign report""" report_id = params["report_id"] self.client.admin_report_unassign(report_id) return { "success": True, "message": f"Report {report_id} unassigned", "data": {"report_id": report_id}, "reversible": True, "reverse_skill": "assign_report", } def _resolve_report(self, params: Dict[str, Any]) -> Dict[str, Any]: """Resolve a report""" report_id = params["report_id"] self.client.admin_report_resolve(report_id) return { "success": True, "message": f"Report {report_id} resolved", "data": {"report_id": report_id}, "reversible": True, "reverse_skill": "reopen_report", } def _reopen_report(self, params: Dict[str, Any]) -> Dict[str, Any]: """Reopen a closed report""" report_id = params["report_id"] self.client.admin_report_reopen(report_id) return { "success": True, "message": f"Report {report_id} reopened", "data": {"report_id": report_id}, "reversible": True, "reverse_skill": "resolve_report", } # ===== FEDERATION MANAGEMENT IMPLEMENTATIONS ===== def _block_domain(self, params: Dict[str, Any]) -> Dict[str, Any]: """Block a domain""" domain = params["domain"] severity = params.get("severity", "suspend") public_comment = params.get("public_comment", "") private_comment = params.get("private_comment", "") reject_media = params.get("reject_media", False) reject_reports = params.get("reject_reports", False) obfuscate = params.get("obfuscate", False) result = self.client.admin_domain_block_create( domain=domain, severity=severity, public_comment=public_comment, private_comment=private_comment, reject_media=reject_media, reject_reports=reject_reports, obfuscate=obfuscate, ) return { "success": True, "message": f"Domain {domain} blocked with severity: {severity}", "data": { "domain": domain, "block_id": str(result.get("id", "")), "severity": severity, }, "reversible": True, "reverse_skill": "unblock_domain", } def _unblock_domain(self, params: Dict[str, Any]) -> Dict[str, Any]: """Unblock a domain""" block_id = params["block_id"] self.client.admin_domain_block_delete(block_id) return { "success": True, "message": f"Domain block {block_id} removed", "data": {"block_id": block_id}, "reversible": True, "reverse_skill": "block_domain", } def _allow_domain(self, params: Dict[str, Any]) -> Dict[str, Any]: """Add domain to allowlist""" domain = params["domain"] result = self.client.admin_domain_allow_create(domain=domain) return { "success": True, "message": f"Domain {domain} added to allowlist", "data": {"domain": domain, "allow_id": str(result.get("id", ""))}, "reversible": True, "reverse_skill": "disallow_domain", } def _disallow_domain(self, params: Dict[str, Any]) -> Dict[str, Any]: """Remove domain from allowlist""" allow_id = params["allow_id"] self.client.admin_domain_allow_delete(allow_id) return { "success": True, "message": f"Domain allow {allow_id} removed", "data": {"allow_id": allow_id}, "reversible": True, "reverse_skill": "allow_domain", } # ===== SECURITY MANAGEMENT IMPLEMENTATIONS ===== def _block_ip(self, params: Dict[str, Any]) -> Dict[str, Any]: """Block IP address""" ip = params["ip"] severity = params["severity"] comment = params["comment"] expires_in = params.get("expires_in") result = self.client.admin_ip_block_create( ip=ip, severity=severity, comment=comment, expires_in=expires_in, ) return { "success": True, "message": f"IP {ip} blocked with severity: {severity}", "data": { "ip": ip, "block_id": str(result.get("id", "")), "severity": severity, "expires_in": expires_in, }, "reversible": True, "reverse_skill": "unblock_ip", } def _unblock_ip(self, params: Dict[str, Any]) -> Dict[str, Any]: """Unblock IP address""" block_id = params["block_id"] self.client.admin_ip_block_delete(block_id) return { "success": True, "message": f"IP block {block_id} removed", "data": {"block_id": block_id}, "reversible": True, "reverse_skill": "block_ip", } def _block_email_domain(self, params: Dict[str, Any]) -> Dict[str, Any]: """Block email domain""" domain = params["domain"] result = self.client.admin_email_domain_block_create(domain=domain) return { "success": True, "message": f"Email domain {domain} blocked", "data": {"domain": domain, "block_id": str(result.get("id", ""))}, "reversible": True, "reverse_skill": "unblock_email_domain", } def _unblock_email_domain(self, params: Dict[str, Any]) -> Dict[str, Any]: """Unblock email domain""" block_id = params["block_id"] self.client.admin_email_domain_block_delete(block_id) return { "success": True, "message": f"Email domain block {block_id} removed", "data": {"block_id": block_id}, "reversible": True, "reverse_skill": "block_email_domain", } # ===== CONSTITUTION MANAGEMENT IMPLEMENTATIONS ===== def _publish_constitution(self, params: Dict[str, Any]) -> Dict[str, Any]: """ Publish constitution as a pinned thread. This will: 1. Check for previously pinned constitution 2. Add deprecation notice to old version 3. Post new constitution as thread 4. Pin the new thread 5. Unpin the old thread """ from pathlib import Path from datetime import datetime constitution_text = params["constitution_text"] change_summary = params.get("change_summary", "Updated constitution") constitution_id_file = Path("config/.constitution_post_id") previous_post_id = None # Check for previous constitution post if constitution_id_file.exists(): try: previous_post_id = constitution_id_file.read_text().strip() logger.info(f"Found previous constitution post: {previous_post_id}") except Exception as e: logger.warning(f"Could not read previous constitution ID: {e}") # Step 1: Deprecate old version if it exists if previous_post_id: try: deprecation_notice = ( f"⚠️ DEPRECATED: This constitution has been superseded.\n\n" f"Changes: {change_summary}\n\n" f"Please see my profile for the current pinned constitution." ) # Reply to the old post with deprecation notice self.client.status_post( status=deprecation_notice, in_reply_to_id=previous_post_id, visibility="public" ) logger.info(f"Added deprecation notice to {previous_post_id}") # Unpin the old post try: self.client.status_unpin(previous_post_id) logger.info(f"Unpinned old constitution post {previous_post_id}") except Exception as e: logger.warning(f"Could not unpin old post: {e}") except Exception as e: logger.error(f"Error deprecating old constitution: {e}") # Step 2: Split constitution into thread-sized chunks # Mastodon posts are limited to 500 chars by default (configurable per instance) max_length = 450 # Leave room for thread indicators # Split by paragraphs first, then combine into chunks paragraphs = constitution_text.split('\n\n') chunks = [] current_chunk = [] current_length = 0 for para in paragraphs: para_length = len(para) + 2 # +2 for paragraph break if current_length + para_length > max_length and current_chunk: # Flush current chunk chunks.append('\n\n'.join(current_chunk)) current_chunk = [para] current_length = para_length else: current_chunk.append(para) current_length += para_length if current_chunk: chunks.append('\n\n'.join(current_chunk)) # Step 3: Post the thread thread_posts = [] last_id = None timestamp = datetime.utcnow().strftime('%Y-%m-%d') for i, chunk in enumerate(chunks, 1): # Add thread indicator and header for first post if i == 1: chunk = f"📜 CONSTITUTION (Updated: {timestamp})\n\nThread 🧵 [{i}/{len(chunks)}]\n\n{chunk}" else: chunk = f"[{i}/{len(chunks)}]\n\n{chunk}" # Post to thread status = self.client.status_post( status=chunk, in_reply_to_id=last_id, visibility="public" ) status_id = str(status["id"]) thread_posts.append(status_id) last_id = status_id logger.info(f"Posted constitution chunk {i}/{len(chunks)}: {status_id}") # Step 4: Pin the first post of the new thread first_post_id = thread_posts[0] try: self.client.status_pin(first_post_id) logger.info(f"Pinned new constitution post: {first_post_id}") except Exception as e: logger.error(f"Failed to pin constitution: {e}") return { "success": False, "message": f"Posted constitution but failed to pin: {str(e)}", "data": {"thread_posts": thread_posts}, "reversible": False, } # Step 5: Save the new post ID try: constitution_id_file.parent.mkdir(parents=True, exist_ok=True) constitution_id_file.write_text(first_post_id) logger.info(f"Saved constitution post ID: {first_post_id}") except Exception as e: logger.error(f"Failed to save constitution post ID: {e}") return { "success": True, "message": ( f"Constitution published as {len(chunks)}-post thread and pinned to profile.\n" f"{'Previous version marked as deprecated.' if previous_post_id else ''}" ), "data": { "thread_posts": thread_posts, "first_post_id": first_post_id, "thread_length": len(chunks), "previous_post_id": previous_post_id, }, "reversible": False, } def _update_profile(self, params: Dict[str, Any]) -> Dict[str, Any]: """Update bot profile information""" display_name = params.get("display_name") note = params.get("note") fields = params.get("fields") # Build update parameters update_params = {} if display_name is not None: update_params["display_name"] = display_name if note is not None: update_params["note"] = note if fields is not None: # Mastodon expects fields as attributes # Format: fields_attributes[0][name], fields_attributes[0][value], etc. for i, field in enumerate(fields[:4]): # Max 4 fields update_params[f"fields_attributes[{i}][name]"] = field.get("name", "") update_params[f"fields_attributes[{i}][value]"] = field.get("value", "") # Update credentials try: result = self.client.account_update_credentials(**update_params) updated_fields = [] if display_name: updated_fields.append("display name") if note: updated_fields.append("bio") if fields: updated_fields.append("profile fields") return { "success": True, "message": f"Profile updated: {', '.join(updated_fields)}", "data": { "display_name": result.get("display_name"), "note": result.get("note"), "fields": result.get("fields", []), }, "reversible": True, } except Exception as e: logger.error(f"Failed to update profile: {e}") return { "success": False, "message": f"Failed to update profile: {str(e)}", "data": {}, "reversible": False, } # ===== WEB-ONLY SKILL IMPLEMENTATIONS ===== def _update_instance_rules(self, params: Dict[str, Any]) -> Dict[str, Any]: """Update instance rules - web admin only""" rules = params.get("rules", []) # Note: Mastodon's API does not provide endpoints for creating/updating rules. # Rules must be managed through the web admin interface. # See: https://docs.joinmastodon.org/methods/instance/ rules_text = "\n".join([f"- {rule}" for rule in rules]) return { "success": False, "message": ( f"Proposed server rules:\n{rules_text}\n\n" "Note: Mastodon's API does not support managing server rules programmatically. " f"To update the server rules, please visit:\n" f"{self.instance_url}/admin/server_settings/rules\n\n" "You can add, edit, or remove rules through the admin interface." ), "data": {"rules": rules}, "reversible": False, "requires_manual_action": True, } def _create_announcement(self, params: Dict[str, Any]) -> Dict[str, Any]: """Create instance announcement - web admin only""" text = params["text"] starts_at = params.get("starts_at") ends_at = params.get("ends_at") # Note: Mastodon's API does not provide endpoints for creating announcements. # Announcements must be created through the web admin interface. # See: https://docs.joinmastodon.org/methods/announcements/ timing_info = "" if starts_at or ends_at: timing_info = "\n\nTiming:" if starts_at: timing_info += f"\n- Start: {starts_at}" if ends_at: timing_info += f"\n- End: {ends_at}" return { "success": False, "message": ( f"Announcement text:\n{text}{timing_info}\n\n" "Note: Mastodon's API does not support creating announcements programmatically. " f"To post this announcement, please visit:\n" f"{self.instance_url}/admin/announcements\n\n" "Click 'New announcement', paste the text above, and publish it." ), "data": {"text": text, "starts_at": starts_at, "ends_at": ends_at}, "reversible": False, "requires_manual_action": True, } 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 with extra spacing text = re.sub(r'^#{1,6}\s+(.+)$', r'\1\n', text, flags=re.MULTILINE) # Convert Markdown lists to simple text with bullets # Ensure each bullet point is on its own line 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) # Ensure proper paragraph spacing for Mastodon # Replace single newlines within paragraphs, but preserve double newlines # First, protect double (or more) newlines text = re.sub(r'\n\n+', '<<>>', text) # Then ensure bullet points and other single newlines are preserved # (Mastodon respects single newlines in plain text) # Restore paragraph breaks text = text.replace('<<>>', '\n\n') # Clean up any extra whitespace but preserve intentional line breaks text = re.sub(r' +', ' ', text) # Multiple spaces -> single space text = re.sub(r'\n ', '\n', text) # Remove spaces after newlines text = re.sub(r' \n', '\n', text) # Remove spaces before newlines return text def _map_visibility(self, visibility: MessageVisibility) -> str: """Map abstract visibility to Mastodon visibility""" mapping = { MessageVisibility.PUBLIC: "public", MessageVisibility.UNLISTED: "unlisted", MessageVisibility.FOLLOWERS: "private", MessageVisibility.DIRECT: "direct", MessageVisibility.PRIVATE: "private", } return mapping.get(visibility, "public") class GovbotStreamListener(StreamListener): """ Mastodon stream listener for governance bot. Listens for notifications (mentions, replies) and calls the callback. """ def __init__( self, bot_id: str, callback: Callable[[PlatformMessage], None], adapter: MastodonAdapter, ): super().__init__() self.bot_id = bot_id self.callback = callback self.adapter = adapter self.running = True def on_notification(self, notification): """Handle incoming notifications""" if not self.running: return # We care about mentions and replies if notification["type"] in ["mention", "reply"]: status = notification["status"] # Don't respond to ourselves if str(status["account"]["id"]) == self.bot_id: return # Convert to PlatformMessage message = self._status_to_message(status) message.mentions_bot = True # It's a notification, so we were mentioned # Call the callback try: self.callback(message) except Exception as e: logger.error(f"Error in message callback: {e}", exc_info=True) def on_update(self, status): """Handle status updates (posts)""" # We primarily care about notifications, not all posts pass 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 (could use BeautifulSoup for better parsing) 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, ) def stop(self): """Stop the listener""" self.running = False