Files
agentic-govbot/src/govbot/platforms/mastodon.py
Nathan Schneider 54beddb420 Add Slack platform adapter and comprehensive platform skills system
Major Features:
- Implemented full Slack channel-bot adapter with Socket Mode
- Added 35+ Mastodon platform skills across 7 categories
- Created constitution publishing system for Mastodon

Slack Adapter (NEW):
- Full PlatformAdapter implementation (1071 lines)
- Socket Mode for real-time events
- 16 channel-scoped governance skills
- User group management as channel "roles"
- Channel access control and management
- Message pinning and moderation
- Platform limitations documentation
- Comprehensive setup guide (SLACK_SETUP.md)

Mastodon Platform Skills (ENHANCED):
- Account Moderation (9 skills): suspend, silence, disable, mark sensitive, delete status
- Account Management (4 skills): approve, reject, delete data, create account
- Report Management (4 skills): assign, unassign, resolve, reopen
- Federation Management (4 skills): block/unblock domains, allow/disallow federation
- Security Management (4 skills): block IP/email domains
- Constitution Management (2 skills): publish constitution, update profile
- Documented web-only limitations (role management requires tootctl)

Constitution Publishing:
- Publish constitution as pinned Mastodon thread
- Automatic deprecation of previous versions
- Version control with change summaries
- Thread splitting for long documents
- CLI tool: scripts/publish_constitution.py
- Documentation: CONSTITUTION_PUBLISHING.md

Configuration & Integration:
- Added SlackConfig model with bot_token, app_token, channel_id
- Updated PlatformConfig to support type: slack
- Added slack-sdk>=3.33.0 dependency
- Bot.py now routes to Slack adapter
- Updated example config with Slack section

Documentation:
- SLACK_SETUP.md: Complete Slack setup guide
- PLATFORM_SKILLS.md: All 35+ Mastodon skills documented
- CONSTITUTION_PUBLISHING.md: Constitution publishing guide
- Updated README.md: Merged QUICKSTART, added Slack to supported platforms
- Updated PLATFORMS.md: Slack marked as implemented with examples
- Updated .gitignore: Added instance-specific state files

Security:
- All sensitive files properly gitignored
- Instance-specific state (.constitution_post_id) excluded
- Credentials properly handled in config

Breaking Changes: None

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-10 22:46:48 -07:00

1725 lines
66 KiB
Python

"""
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+', '<<<PARAGRAPH>>>', text)
# Then ensure bullet points and other single newlines are preserved
# (Mastodon respects single newlines in plain text)
# Restore paragraph breaks
text = text.replace('<<<PARAGRAPH>>>', '\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