Initial commit: Platform-agnostic governance bot

Govbot is an AI-powered governance bot that interprets natural language
constitutions and facilitates collective decision-making across social
platforms.

Core features:
- Agentic architecture with constitutional reasoning (RAG)
- Platform-agnostic design (Mastodon, Discord, Telegram, etc.)
- Action primitives for flexible governance processes
- Temporal awareness for multi-day proposals and voting
- Audit trail with constitutional citations
- Reversible actions with supermajority veto
- Works with local (Ollama) and cloud AI models

Platform support:
- Mastodon: Full implementation with streaming, moderation, and admin skills
- Discord/Telegram: Platform abstraction ready for implementation

Documentation:
- README.md: Architecture and overview
- QUICKSTART.md: Getting started guide
- PLATFORMS.md: Platform implementation guide for developers
- MASTODON_SETUP.md: Complete Mastodon deployment guide
- constitution.md: Example governance constitution

Technical stack:
- Python 3.11+
- SQLAlchemy for state management
- llm CLI for model abstraction
- Mastodon.py for Mastodon integration
- Pydantic for configuration validation

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
Nathan Schneider
2026-02-06 17:09:26 -07:00
commit fbc37ecb8f
27 changed files with 6004 additions and 0 deletions

View File

@@ -0,0 +1,680 @@
"""
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 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 Mastodon streaming API.
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 stream listener for mentions")
# Create stream listener
self.stream_listener = GovbotStreamListener(
bot_id=self.bot_user_id,
callback=callback,
adapter=self,
)
# Start streaming in a separate thread
def stream_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)
self.listener_thread = threading.Thread(target=stream_thread, daemon=True)
self.listener_thread.start()
logger.info("Stream listener 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 (max 500 characters for most instances)
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")
# 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 [
# Moderation skills
PlatformSkill(
name="suspend_account",
description="Suspend a user account (reversible)",
category="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="silence_account",
description="Silence a user account (hide from public timelines)",
category="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="delete_status",
description="Delete a status/post",
category="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",
),
# Instance administration skills
PlatformSkill(
name="update_instance_rules",
description="Update instance rules/code of conduct",
category="admin",
parameters=[
SkillParameter("rules", "list", "List of rule texts"),
],
requires_confirmation=True,
reversible=True,
constitutional_authorization="Requires constitutional amendment process",
),
PlatformSkill(
name="update_instance_description",
description="Update instance description/about page",
category="admin",
parameters=[
SkillParameter("description", "str", "New instance description"),
],
requires_confirmation=True,
reversible=True,
constitutional_authorization="Requires governance approval",
),
PlatformSkill(
name="grant_moderator",
description="Grant moderator role to a user",
category="admin",
parameters=[
SkillParameter("account_id", "str", "Account ID to promote"),
],
requires_confirmation=True,
reversible=True,
constitutional_authorization="Requires governance approval",
),
PlatformSkill(
name="revoke_moderator",
description="Revoke moderator role from a user",
category="admin",
parameters=[
SkillParameter("account_id", "str", "Account ID to demote"),
],
requires_confirmation=True,
reversible=True,
constitutional_authorization="Requires governance approval",
),
# Content management
PlatformSkill(
name="create_announcement",
description="Create an instance-wide announcement",
category="content",
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 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:
if skill_name == "suspend_account":
return self._suspend_account(parameters)
elif skill_name == "silence_account":
return self._silence_account(parameters)
elif skill_name == "delete_status":
return self._delete_status(parameters)
elif skill_name == "update_instance_rules":
return self._update_instance_rules(parameters)
elif skill_name == "update_instance_description":
return self._update_instance_description(parameters)
elif skill_name == "grant_moderator":
return self._grant_moderator(parameters)
elif skill_name == "revoke_moderator":
return self._revoke_moderator(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 for skill execution
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_params": {"account_id": account_id, "action": "unsuspend"},
}
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_params": {"account_id": account_id, "action": "unsilence"},
}
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,
}
def _update_instance_rules(self, params: Dict[str, Any]) -> Dict[str, Any]:
"""Update instance rules"""
# Note: This requires admin API access
# Implementation depends on Mastodon version and API availability
rules = params["rules"]
# This would use admin API to update instance rules
# Exact implementation varies by Mastodon version
return {
"success": True,
"message": f"Updated instance rules ({len(rules)} rules)",
"data": {"rules": rules},
"reversible": True,
"reverse_params": {"rules": "previous_rules"}, # Would need to store previous
}
def _update_instance_description(self, params: Dict[str, Any]) -> Dict[str, Any]:
"""Update instance description"""
description = params["description"]
# This would use admin API
# Exact implementation varies
return {
"success": True,
"message": "Updated instance description",
"data": {"description": description},
"reversible": True,
"reverse_params": {"description": "previous_description"},
}
def _grant_moderator(self, params: Dict[str, Any]) -> Dict[str, Any]:
"""Grant moderator role"""
account_id = params["account_id"]
# Use admin API to update role
self.client.admin_account_moderate(account_id, action="promote_moderator")
return {
"success": True,
"message": f"Granted moderator to account {account_id}",
"data": {"account_id": account_id},
"reversible": True,
"reverse_params": {"account_id": account_id},
}
def _revoke_moderator(self, params: Dict[str, Any]) -> Dict[str, Any]:
"""Revoke moderator role"""
account_id = params["account_id"]
# Use admin API to update role
self.client.admin_account_moderate(account_id, action="demote_moderator")
return {
"success": True,
"message": f"Revoked moderator from account {account_id}",
"data": {"account_id": account_id},
"reversible": True,
"reverse_params": {"account_id": account_id},
}
def _create_announcement(self, params: Dict[str, Any]) -> Dict[str, Any]:
"""Create instance announcement"""
text = params["text"]
starts_at = params.get("starts_at")
ends_at = params.get("ends_at")
announcement = self.client.admin_announcement_create(
text=text,
starts_at=starts_at,
ends_at=ends_at,
)
return {
"success": True,
"message": "Created announcement",
"data": {"announcement_id": announcement["id"], "text": text},
"reversible": True,
"reverse_params": {"announcement_id": announcement["id"]},
}
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