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>
1725 lines
66 KiB
Python
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
|