diff --git a/.gitignore b/.gitignore index 73f01ab..36ba7b7 100644 --- a/.gitignore +++ b/.gitignore @@ -58,6 +58,9 @@ api_keys.json *.sqlite *.sqlite3 +# Instance-specific state +config/.constitution_post_id + # Backups (may contain sensitive data) *.backup *.bak diff --git a/CONSTITUTION_PUBLISHING.md b/CONSTITUTION_PUBLISHING.md new file mode 100644 index 0000000..a5801b5 --- /dev/null +++ b/CONSTITUTION_PUBLISHING.md @@ -0,0 +1,311 @@ +# Constitution Publishing + +The bot can publish and maintain its constitution as a pinned thread on Mastodon, creating a transparent, versioned record of governance rules. + +## How It Works + +### Constitutional Version Control + +When you publish or update the constitution: + +1. **Previous Version Deprecated**: The old pinned constitution gets a deprecation notice +2. **New Version Posted**: Constitution is split into a thread (~450 chars per post) +3. **Thread Pinned**: First post is pinned to the bot's profile +4. **History Preserved**: Old versions remain visible with deprecation notices +5. **ID Tracked**: Post ID saved to `config/.constitution_post_id` for future updates + +### Thread Format + +``` +πŸ“œ CONSTITUTION (Updated: 2026-02-10) + +Thread 🧡 [1/5] + +[Constitution content...] + +[2/5] +[More content...] + +[3/5] +[More content...] +``` + +### Deprecation Notice (on old versions) + +``` +⚠️ DEPRECATED: This constitution has been superseded. + +Changes: Updated voting thresholds and added new roles + +Please see my profile for the current pinned constitution. +``` + +## Publishing Methods + +### Method 1: Via Bot Command (Recommended) + +Ask the bot directly via Mastodon: + +``` +@govbot please publish the current constitution +``` + +The bot will: +- Read `constitution.md` +- Check authority per constitution +- Post and pin the thread +- Report success/failure + +### Method 2: Helper Script + +Use the provided script for direct publishing: + +```bash +# Basic usage +python scripts/publish_constitution.py + +# With change summary +python scripts/publish_constitution.py --summary "Added role-based permissions" + +# Custom constitution file +python scripts/publish_constitution.py --constitution docs/governance.md + +# Full options +python scripts/publish_constitution.py \ + --constitution constitution.md \ + --summary "Updated voting thresholds" \ + --config config/config.yaml +``` + +**Script options:** +- `--summary` / `-s`: Description of changes (shown in deprecation notice) +- `--constitution` / `-c`: Path to constitution file (default: `constitution.md`) +- `--config`: Path to config file (default: `config/config.yaml`) + +### Method 3: Python API + +Programmatically publish from your own code: + +```python +from src.govbot.utils.config import load_config +from src.govbot.platforms.mastodon import MastodonAdapter + +# Load config and connect +config = load_config("config/config.yaml") +adapter = MastodonAdapter(config.platform.mastodon.model_dump()) +adapter.connect() + +# Publish constitution +result = adapter.execute_skill( + skill_name="publish_constitution", + parameters={ + "constitution_text": constitution_text, + "change_summary": "What changed in this version", + }, + actor="@admin" +) + +print(result["message"]) +``` + +## Profile Integration + +### Update Profile to Reference Constitution + +The bot can also update its profile to highlight the constitution: + +```python +# Via bot command +@govbot update your profile to mention the constitution in your bio + +# Via Python API +adapter.execute_skill( + skill_name="update_profile", + parameters={ + "display_name": "Govbot", + "note": "πŸ€– Governance bot for democratic communities.\n\nπŸ“œ Constitution pinned to profile.", + "fields": [ + {"name": "Constitution", "value": "πŸ“œ See pinned post"}, + {"name": "Governance", "value": "Consensus-based"}, + {"name": "Source Code", "value": "github.com/you/repo"}, + {"name": "Contact", "value": "@admin"} + ] + }, + actor="@admin" +) +``` + +Profile fields appear as a table on the bot's profile page. + +## Best Practices + +### Initial Publication + +When first setting up the bot: + +1. **Finalize Constitution**: Make sure `constitution.md` is complete +2. **Set Profile**: Update bio and fields to reference constitution +3. **Publish Constitution**: Run publish script or ask bot +4. **Verify**: Visit bot's profile to confirm pinned thread + +### Updating Constitution + +When making changes: + +1. **Edit File**: Update `constitution.md` with changes +2. **Write Summary**: Prepare clear description of what changed +3. **Test Locally**: Use CLI mode to test if needed +4. **Publish Update**: Run script with `--summary` flag +5. **Verify**: Check that: + - Old version has deprecation notice + - New version is pinned + - Profile still references constitution + +### Version Control + +Consider maintaining constitution in git: + +```bash +# Track changes +git add constitution.md +git commit -m "Update voting thresholds from 60% to 66%" + +# Publish to Mastodon +python scripts/publish_constitution.py \ + --summary "Update voting thresholds from 60% to 66%" + +# Push to repo +git push +``` + +This gives you: +- **Git history**: Full version control with diffs +- **Mastodon thread history**: Public, timestamped versions +- **Deprecation chain**: Links between versions + +## Example Workflow + +### Scenario: Updating Voting Rules + +```bash +# 1. Edit constitution +nano constitution.md +# (Change "50% majority" to "60% supermajority") + +# 2. Test understanding (optional) +python -m src.govbot.governance.constitution "What is the voting threshold?" +# Verify it understands the new rule + +# 3. Commit to git +git add constitution.md +git commit -m "Increase voting threshold to supermajority" + +# 4. Publish to Mastodon +python scripts/publish_constitution.py \ + --summary "Increased voting threshold from 50% to 60%" + +# Output: +# πŸ“œ Loaded constitution from constitution.md (4532 chars) +# πŸ”Œ Connecting to https://govbot.modpol.net... +# βœ… Connected as @govbot +# πŸ“€ Publishing constitution... +# Change summary: Increased voting threshold from 50% to 60% +# βœ… Constitution published as 8-post thread and pinned to profile. +# Previous version marked as deprecated. +# πŸ“Š Details: +# - Thread length: 8 posts +# - First post ID: 123456789 +# - Previous post ID: 123456700 (deprecated) +# πŸ”— View at: https://govbot.modpol.net/@govbot +# ✨ Done! + +# 5. Verify +# Visit https://govbot.modpol.net/@govbot +# - New thread is pinned +# - Old thread has deprecation notice +``` + +## Technical Details + +### File Storage + +The current constitution post ID is stored in: +``` +config/.constitution_post_id +``` + +This file is **gitignored** but tracked by the bot to find the previous version when updating. + +### Thread Splitting Algorithm + +The bot splits constitution into chunks: + +1. **Split by paragraphs**: Preserves semantic boundaries +2. **Combine into chunks**: Up to 450 chars each (leaving room for thread indicators) +3. **Add thread indicators**: `[1/8]`, `[2/8]`, etc. +4. **First post header**: Includes emoji, date, and thread indicator +5. **Post sequentially**: Each replies to the previous + +### Post Visibility + +Constitution posts are always: +- **Public visibility**: Anyone can see and share +- **Threaded**: Each post replies to previous +- **Pinned**: First post pinned to profile (max 5 pins) + +### Mastodon API Requirements + +Publishing requires: +- `write:accounts` scope (for pinning/unpinning) +- `write:statuses` scope (for posting) +- Bot must be connected and authenticated + +## Troubleshooting + +### "Failed to pin constitution" + +**Cause**: Too many posts already pinned (max 5) + +**Solution**: Manually unpin old posts from web interface at Settings β†’ Profile β†’ Pinned posts + +### "Constitution file not found" + +**Cause**: File path incorrect + +**Solution**: Verify file exists: +```bash +ls -la constitution.md +``` + +### "Not authorized to publish" + +**Cause**: Bot doesn't have authority per constitution + +**Solution**: Either: +- Grant bot authority in constitution +- Use script directly (bypasses authorization check) + +### "Previous version not found" + +**Cause**: `.constitution_post_id` file missing or contains invalid ID + +**Solution**: +- First publication: This is normal, ignore +- Subsequent: Old version won't be deprecated (but new version will still pin) + +## Future Enhancements + +Potential improvements: + +- **Diff Display**: Show exact changes between versions +- **Amendment Tracking**: Link to governance processes that authorized changes +- **Multilingual**: Publish translations as separate threads +- **Rich Formatting**: Use custom emojis or instance-specific formatting +- **Automatic Publishing**: Trigger on git push or governance process completion + +## See Also + +- [PLATFORM_SKILLS.md](PLATFORM_SKILLS.md) - All available platform skills +- [ARCHITECTURE.md](ARCHITECTURE.md) - How the bot works +- [Mastodon API - Update Credentials](https://docs.joinmastodon.org/methods/accounts/#update_credentials) +- [Mastodon API - Pin Status](https://docs.joinmastodon.org/methods/statuses/#pin) diff --git a/PLATFORMS.md b/PLATFORMS.md index d52dca0..12c3f59 100644 --- a/PLATFORMS.md +++ b/PLATFORMS.md @@ -4,7 +4,16 @@ This guide explains how to implement Govbot adapters for new social/communicatio ## Overview -Govbot uses a **platform-agnostic architecture** that separates governance logic from platform-specific code. This allows the same constitutional reasoning and governance processes to work across different platforms (Mastodon, Discord, Telegram, Matrix, etc.). +Govbot uses a **platform-agnostic architecture** that separates governance logic from platform-specific code. This allows the same constitutional reasoning and governance processes to work across different platforms. + +**Currently Implemented**: +- βœ… **Mastodon** - Instance-wide governance with full admin/moderation API +- βœ… **Slack** - Channel-scoped governance with Socket Mode + +**Planned**: +- 🚧 **Discord** - Server-wide governance (see example skeleton below) +- 🚧 **Telegram** - Group governance +- 🚧 **Matrix** - Room governance The key abstraction is the **PlatformAdapter** interface, which defines how Govbot interacts with any platform. @@ -841,8 +850,10 @@ class DiscordAdapter(PlatformAdapter): ## Getting Help -- Review the Mastodon adapter for a complete example -- Check the base PlatformAdapter for interface documentation +- Review the **Mastodon adapter** (`src/govbot/platforms/mastodon.py`) for an instance-wide governance example +- Review the **Slack adapter** (`src/govbot/platforms/slack.py`) for a channel-scoped governance example +- Check the base PlatformAdapter (`src/govbot/platforms/base.py`) for interface documentation +- See setup guides: [MASTODON_SETUP.md](MASTODON_SETUP.md), [SLACK_SETUP.md](SLACK_SETUP.md) - Look at test files for testing patterns - Ask questions in project discussions diff --git a/PLATFORM_SKILLS.md b/PLATFORM_SKILLS.md new file mode 100644 index 0000000..dafb8db --- /dev/null +++ b/PLATFORM_SKILLS.md @@ -0,0 +1,372 @@ +# Mastodon Platform Skills + +Complete listing of all platform-specific governance skills available through the Mastodon API. + +## Overview + +The bot now implements **35+ platform skills** organized into 7 categories: +- Account Moderation (9 skills) +- Account Management (4 skills) +- Report Management (4 skills) +- Federation Management (4 skills) +- Security Management (4 skills) +- Instance Administration (2 skills) +- Web-Only Actions (2 skills - informational only) + +## Account Moderation Skills + +### suspend_account +**Description**: Suspend a user account (reversible, blocks login and hides content) +**Parameters**: +- `account_id` (str): Account ID to suspend +- `reason` (str): Reason for suspension +**Reversible**: Yes (use `unsuspend_account`) +**Authorization**: Requires moderation authority per constitution + +### unsuspend_account +**Description**: Lift suspension from an account +**Parameters**: +- `account_id` (str): Account ID to unsuspend +**Reversible**: Yes (can suspend again) +**Authorization**: Requires moderation authority per constitution + +### silence_account +**Description**: Silence a user account (hide from public timelines, reversible) +**Parameters**: +- `account_id` (str): Account ID to silence +- `reason` (str): Reason for silencing +**Reversible**: Yes (use `unsilence_account`) +**Authorization**: Requires moderation authority per constitution + +### unsilence_account +**Description**: Lift silence from an account +**Parameters**: +- `account_id` (str): Account ID to unsilence +**Reversible**: Yes (can silence again) +**Authorization**: Requires moderation authority per constitution + +### disable_account +**Description**: Disable local account login (reversible) +**Parameters**: +- `account_id` (str): Account ID to disable +- `reason` (str): Reason for disabling +**Reversible**: Yes (use `enable_account`) +**Authorization**: Requires moderation authority per constitution + +### enable_account +**Description**: Re-enable a disabled local account +**Parameters**: +- `account_id` (str): Account ID to enable +**Reversible**: Yes (can disable again) +**Authorization**: Requires moderation authority per constitution + +### mark_account_sensitive +**Description**: Mark account's media as always sensitive +**Parameters**: +- `account_id` (str): Account ID to mark +- `reason` (str): Reason for marking sensitive +**Reversible**: Yes (use `unmark_account_sensitive`) +**Authorization**: Requires moderation authority per constitution + +### unmark_account_sensitive +**Description**: Remove sensitive flag from account +**Parameters**: +- `account_id` (str): Account ID to unmark +**Reversible**: Yes (can mark sensitive again) +**Authorization**: Requires moderation authority per constitution + +### delete_status +**Description**: Delete a status/post (permanent) +**Parameters**: +- `status_id` (str): Status ID to delete +- `reason` (str): Reason for deletion +**Reversible**: No +**Authorization**: Requires moderation authority per constitution + +## Account Management Skills + +### approve_account +**Description**: Approve a pending account registration +**Parameters**: +- `account_id` (str): Account ID to approve +**Reversible**: No +**Authorization**: Requires account approval authority per constitution + +### reject_account +**Description**: Reject a pending account registration (permanent) +**Parameters**: +- `account_id` (str): Account ID to reject +**Reversible**: No +**Authorization**: Requires account approval authority per constitution + +### delete_account_data +**Description**: Permanently delete all data for a suspended account (IRREVERSIBLE) +**Parameters**: +- `account_id` (str): Suspended account ID to delete +**Reversible**: No +**Note**: Account must already be suspended before data deletion +**Authorization**: Requires highest level authority per constitution + +### create_account +**Description**: Create a new user account (requires email verification, may need approval) +**Parameters**: +- `username` (str): Desired username +- `email` (str): Email address +- `password` (str): Account password +- `reason` (str, optional): Registration reason (if approval required) +**Reversible**: No +**Limitations**: +- User must verify email before account is active +- May require manual moderator approval depending on instance settings +- Only creates regular user accounts (not admin accounts) +**Authorization**: Requires account creation authority per constitution + +## Report Management Skills + +### assign_report +**Description**: Assign a report to yourself for handling +**Parameters**: +- `report_id` (str): Report ID to assign +**Reversible**: Yes (use `unassign_report`) +**Authorization**: Requires report management authority per constitution + +### unassign_report +**Description**: Unassign a report so others can claim it +**Parameters**: +- `report_id` (str): Report ID to unassign +**Reversible**: Yes (can reassign) +**Authorization**: Requires report management authority per constitution + +### resolve_report +**Description**: Mark a report as resolved +**Parameters**: +- `report_id` (str): Report ID to resolve +**Reversible**: Yes (use `reopen_report`) +**Authorization**: Requires report management authority per constitution + +### reopen_report +**Description**: Reopen a closed report +**Parameters**: +- `report_id` (str): Report ID to reopen +**Reversible**: Yes (can resolve again) +**Authorization**: Requires report management authority per constitution + +## Federation Management Skills + +### block_domain +**Description**: Block federation with a domain +**Parameters**: +- `domain` (str): Domain to block +- `severity` (str): Block severity: "silence", "suspend", or "noop" +- `public_comment` (str): Public reason for block +- `private_comment` (str, optional): Internal note +- `reject_media` (bool, optional): Reject media files from domain +- `reject_reports` (bool, optional): Reject reports from domain +- `obfuscate` (bool, optional): Hide domain name publicly +**Reversible**: Yes (use `unblock_domain`) +**Authorization**: Requires federation management authority per constitution + +### unblock_domain +**Description**: Remove domain from block list +**Parameters**: +- `block_id` (str): Domain block ID to remove +**Reversible**: Yes (can block again) +**Authorization**: Requires federation management authority per constitution + +### allow_domain +**Description**: Add domain to allowlist (for LIMITED_FEDERATION_MODE) +**Parameters**: +- `domain` (str): Domain to allow +**Reversible**: Yes (use `disallow_domain`) +**Authorization**: Requires federation management authority per constitution + +### disallow_domain +**Description**: Remove domain from allowlist +**Parameters**: +- `allow_id` (str): Domain allow ID to remove +**Reversible**: Yes (can allow again) +**Authorization**: Requires federation management authority per constitution + +## Security Management Skills + +### block_ip +**Description**: Block IP address or range +**Parameters**: +- `ip` (str): IP address with CIDR prefix (e.g., "192.168.0.1/24") +- `severity` (str): Block severity: "sign_up_requires_approval", "sign_up_block", or "no_access" +- `comment` (str): Reason for IP block +- `expires_in` (int, optional): Expiration time in seconds +**Reversible**: Yes (use `unblock_ip`) +**Authorization**: Requires security management authority per constitution + +### unblock_ip +**Description**: Remove IP block +**Parameters**: +- `block_id` (str): IP block ID to remove +**Reversible**: Yes (can block again) +**Authorization**: Requires security management authority per constitution + +### block_email_domain +**Description**: Block email domain from registrations +**Parameters**: +- `domain` (str): Email domain to block (e.g., "spam.com") +**Reversible**: Yes (use `unblock_email_domain`) +**Authorization**: Requires security management authority per constitution + +### unblock_email_domain +**Description**: Remove email domain from block list +**Parameters**: +- `block_id` (str): Email domain block ID to remove +**Reversible**: Yes (can block again) +**Authorization**: Requires security management authority per constitution + +## Constitution Management Skills + +### publish_constitution +**Description**: Post constitution as pinned thread (deprecates previous version) +**Parameters**: +- `constitution_text` (str): Full constitution text in markdown +- `change_summary` (str, optional): Summary of what changed +**Reversible**: No +**Authorization**: Requires constitutional amendment process +**Behavior**: +1. Finds previously pinned constitution (if any) +2. Adds deprecation notice to old version +3. Splits constitution into thread-sized chunks (~450 chars each) +4. Posts as public thread +5. Pins new thread to profile +6. Unpins old thread +7. Saves post ID for future updates + +### update_profile +**Description**: Update bot profile information (bio, fields, display name) +**Parameters**: +- `display_name` (str, optional): Display name +- `note` (str, optional): Bio/description +- `fields` (list, optional): Profile fields (max 4, each with 'name' and 'value') +**Reversible**: Yes +**Authorization**: Requires governance approval +**Example fields**: +```json +[ + {"name": "Constitution", "value": "πŸ“œ See pinned post"}, + {"name": "Governance", "value": "Consensus-based"}, + {"name": "Source", "value": "github.com/..."} +] +``` + +## Instance Administration Skills + +**⚠️ IMPORTANT**: Role management is **NOT available via the Mastodon API**. See "Platform Limitations" section below for details. + +## Web-Only Actions (Not Available via API) + +These skills are included in the bot's knowledge so it can explain limitations to users. + +### update_instance_rules +**Description**: Update instance rules/code of conduct +**Status**: WEB-ONLY - Must be done through admin interface +**Web Interface**: `/admin/server_settings/rules` +**Note**: Mastodon API does not provide endpoints for managing server rules programmatically + +### create_announcement +**Description**: Create an instance-wide announcement +**Status**: WEB-ONLY - Must be done through admin interface +**Web Interface**: `/admin/announcements` +**Note**: Mastodon API does not provide endpoints for creating announcements programmatically + +## Platform Limitations + +The bot is now aware of the following limitations and can explain them to users: + +### Actions Requiring Web Interface +- **Instance Rules**: Creating and editing server rules must be done at `/admin/server_settings/rules` +- **Announcements**: Creating instance-wide announcements must be done at `/admin/announcements` +- **Role Management**: ALL role operations (grant/revoke moderator, admin, or custom roles) must be done at `/admin/roles` +- **Instance Settings**: Modifying instance description, contact info, etc. requires web admin access + +### Actions Not Possible via API +- **Role Management**: The Mastodon API does **NOT** support granting or revoking ANY roles (moderator, admin, or custom). You must use: + - **Web interface**: `/admin/roles` + - **Command line**: `tootctl accounts modify username --role RoleName` +- **Admin Account Creation**: Admin accounts cannot be created via API: + ```bash + RAILS_ENV=production bin/tootctl accounts create username --email user@example.com --confirmed --role Owner + ``` + +### General 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 + +## How It Works + +1. **Skill Discovery**: When a user makes a request, the bot receives the full list of platform skills +2. **Limitation Awareness**: The bot also receives information about what's not possible via API +3. **Intelligent Routing**: The LLM agent decides whether to: + - Execute a platform skill (if available and authorized) + - Explain a limitation (if requested action requires web interface) + - Create a governance process (if actor lacks authority) + - Respond with information (if just asking a question) + +## Example Interactions + +### Successful Platform Skill Execution +``` +User: @govbot please suspend account 12345 for spam +Bot: I've suspended account 12345 for spam. + This action is reversible using the unsuspend_account skill. +``` + +### Web-Only Limitation +``` +User: @govbot please update the instance rules +Bot: Instance rules cannot be updated via the API. + To update server rules, please visit: + https://your-instance.com/admin/server_settings/rules + + You can add, edit, or remove rules through the admin interface. +``` + +### Account Creation with Limitations +``` +User: @govbot create an account for newuser@example.com +Bot: Account @newuser created successfully. + + Important: The user must verify their email before they can log in. + Depending on instance settings, manual approval may also be required. +``` + +## Technical Implementation + +### Code Changes +1. **Mastodon Adapter** (`src/govbot/platforms/mastodon.py`): + - Added 35+ platform skills across 7 categories + - Implemented execution methods for all API-available skills + - Added `get_platform_limitations()` method to document limitations + - Web-only skills return helpful messages with admin interface URLs + +2. **Governance Agent** (`src/govbot/agent.py`): + - Updated `process_request()` to accept `platform_limitations` parameter + - Enhanced decision-making prompt to include limitation information + - Agent now explains limitations when users request unavailable actions + +3. **Bot Integration** (`src/govbot/bot.py`): + - Retrieves platform limitations alongside platform skills + - Passes limitations to agent for informed decision-making + +### API Requirements +All admin skills require: +- Appropriate OAuth scopes (`admin:read`, `admin:write`, etc.) +- Role permissions (Manage Users, Manage Reports, Manage Federation, etc.) +- Active Mastodon.py connection with admin access token + +## Documentation Sources +- [Mastodon Admin Accounts API](https://docs.joinmastodon.org/methods/admin/accounts/) +- [Mastodon Admin Reports API](https://docs.joinmastodon.org/methods/admin/reports/) +- [Mastodon Admin Domain Blocks API](https://docs.joinmastodon.org/methods/admin/domain_blocks/) +- [Mastodon Admin IP Blocks API](https://docs.joinmastodon.org/methods/admin/ip_blocks/) +- [Mastodon Admin Email Domain Blocks API](https://docs.joinmastodon.org/methods/admin/email_domain_blocks/) +- [Mastodon Accounts API](https://docs.joinmastodon.org/methods/accounts/) +- [Mastodon OAuth Scopes](https://docs.joinmastodon.org/api/oauth-scopes/) diff --git a/QUICKSTART.md b/QUICKSTART.md deleted file mode 100644 index f3cf707..0000000 --- a/QUICKSTART.md +++ /dev/null @@ -1,252 +0,0 @@ -# Govbot Quick Start Guide - -This guide will help you get Govbot up and running. - -## Prerequisites - -- Python 3.11 or higher -- `llm` CLI tool installed (`pip install llm`) -- Ollama installed with a model (e.g., `ollama pull llama3.2`) OR API keys for cloud models - -## Installation - -### 1. Install Dependencies - -Using `uv` (faster, recommended): -```bash -# Install uv if you don't have it -curl -LsSf https://astral.sh/uv/install.sh | sh - -# Install dependencies -uv pip install -e . -``` - -Using regular pip: -```bash -pip install -e . -``` - -### 2. Configure the Bot - -```bash -# Copy example config -cp config/config.example.yaml config/config.yaml - -# Edit with your credentials and settings -nano config/config.yaml -``` - -**⚠️ IMPORTANT - Security Notice**: -- `config/config.yaml` contains your secrets (API tokens, passwords) -- This file is automatically gitignored - it will NEVER be committed -- Never share this file or commit it to version control -- See [SECURITY.md](SECURITY.md) for complete security guidance - -For local models with Ollama: -```yaml -ai: - default_model: llama3.2 # or whatever model you have -``` - -For cloud models: -```yaml -ai: - default_model: gpt-4 # or claude-3-sonnet, etc. -``` - -Make sure the `llm` CLI is configured for your chosen model: -```bash -# Test llm -llm "Hello, how are you?" - -# Configure for cloud models if needed -llm keys set openai # for OpenAI -llm keys set anthropic # for Anthropic Claude -``` - -### 3. Initialize the Database - -The database will be created automatically on first run, but you can verify: - -```bash -python -c "from src.govbot.db.models import init_db; init_db('govbot.db'); print('Database initialized!')" -``` - -## Testing Without Mastodon - -The CLI allows you to test all governance features without connecting to Mastodon: - -```bash -# Run the interactive CLI -python -m src.govbot -``` - -Try these commands: - -``` -# View the constitution -constitution - -# Ask a constitutional question -query What are the rules for creating a proposal? - -# Create a test proposal -propose We should update the moderation guidelines - -# Check active processes -processes - -# Vote on a process (use the ID from processes command) -vote 1 agree - -# Check process status -status 1 - -# View recent actions -actions -``` - -## Testing the Constitutional Reasoner - -You can test the constitutional reasoning engine directly: - -```bash -python -m src.govbot.governance.constitution "What voting thresholds are required for different proposal types?" -``` - -## Understanding the Architecture - -### Component Overview - -1. **Constitution (constitution.md)**: The governance rules in natural language -2. **Database (govbot.db)**: SQLite database storing all governance state -3. **AI Agent (src/govbot/agent.py)**: Interprets requests and orchestrates actions -4. **Primitives (src/govbot/governance/primitives.py)**: Low-level governance operations -5. **Constitutional Reasoner (src/govbot/governance/constitution.py)**: RAG system for understanding the constitution -6. **Scheduler (src/govbot/scheduler.py)**: Background tasks for deadlines and reminders - -### How It Works - -``` -User Request - ↓ -AI Agent parses intent - ↓ -Queries Constitution (RAG) - ↓ -Plans primitive actions - ↓ -Executes with audit logging - ↓ -Returns response -``` - -## Example: Creating a Proposal - -Let's walk through what happens when you create a proposal: - -```bash -$ python -m src.govbot -@testuser> propose We should add weekly community meetings -``` - -The bot will: -1. **Parse** your intent (creating a proposal) -2. **Query** the constitution about proposal rules -3. **Determine** this is a standard proposal (6 days, simple majority) -4. **Create** a governance process in the database -5. **Schedule** a reminder for the deadline -6. **Respond** with the proposal details and how to vote - -You can then vote: -``` -@testuser> vote 1 agree -``` - -And check status: -``` -@testuser> status 1 -``` - -The scheduler (running in background) will automatically close the proposal after 6 days and count votes. - -## Next Steps - -### Customizing the Constitution - -Edit `constitution.md` to match your community's governance needs. The AI will adapt to whatever you write! - -Key things to include: -- Proposal types and their requirements -- Voting thresholds -- Member rights and responsibilities -- Administrative procedures -- Safety mechanisms (veto, appeals) - -### Connecting to Mastodon - -To connect to a real Mastodon instance, you'll need to: - -1. Create a bot account on your instance -2. Register an application to get OAuth credentials -3. Update `config/config.yaml` with your Mastodon settings -4. Implement the Mastodon streaming listener (currently a stub in `bot.py`) - -See [MASTODON.md](MASTODON.md) for detailed instructions (coming soon). - -### Adding Custom Governance Skills - -You can extend the bot with custom Mastodon-specific actions: - -1. Add new primitives in `src/govbot/governance/primitives.py` -2. Update the agent's planning logic in `src/govbot/agent.py` -3. Update the constitution to reference new capabilities - -### Running in Production - -For production deployment: - -1. Set up proper logging (see `config.yaml`) -2. Use a systemd service or supervisor to keep it running -3. Set up monitoring for the database and process queue -4. Consider running on a dedicated server or container -5. Implement backup procedures for the database - -## Troubleshooting - -### "Could not parse request" error -- Check that `llm` CLI is working: `llm "test"` -- Verify your model is installed: `ollama list` or check API keys -- Try a simpler request to test - -### "Constitution not found" error -- Make sure `constitution.md` exists in the root directory -- Check the path in `config/config.yaml` - -### Database errors -- Delete `govbot.db` and let it reinitialize -- Check file permissions - -### Model not responding -- Test llm directly: `llm -m llama3.2 "hello"` -- Check Ollama is running: `ollama list` -- For cloud models, verify API keys: `llm keys list` - -## Getting Help - -- Check the [README.md](README.md) for architecture details -- Review the constitution examples -- Look at the code - it's designed to be readable! -- Open an issue if you find bugs - -## Philosophy - -Govbot is designed around a few key principles: - -1. **Agentic, not procedural**: The bot interprets natural language constitutions rather than hard-coding governance procedures -2. **Transparent**: All actions are logged with constitutional reasoning -3. **Reversible**: The community can override bot decisions -4. **Flexible**: Works with any constitution you write -5. **Democratic**: Enables collective governance within social platforms - -Have fun governing! πŸ›οΈ diff --git a/README.md b/README.md index 435ae94..6bddf81 100644 --- a/README.md +++ b/README.md @@ -2,184 +2,158 @@ An agentic governance bot for democratic communities that interprets natural language constitutions and facilitates collective decision-making across social platforms. -## Overview - -Govbot is designed to: -- Read and interpret governance constitutions written in natural language -- Facilitate any governance process defined in your constitution (proposals, disputes, elections, discussions, etc.) -- Execute administrative actions based on constitutional rules -- Maintain an audit trail of all governance activities -- Support both local (Ollama) and cloud AI models -- **Work across multiple platforms** (Mastodon, Discord, Telegram, etc.) - **Key Concept**: Govbot uses "process" as the central abstraction - a generic container for any governance activity (proposals, disputes, elections, etc.). Process types are not hard-coded; the LLM interprets your constitution to understand what types exist and how they work. ## Features - **Pure LLM-Driven Governance**: No hard-coded governance logic - the LLM interprets the constitution and makes all decisions - **Structured Memory System**: Tracks governance processes, events, and decisions in a queryable format -- **LLM Tools for Correctness**: Deterministic tools for math, dates, and random selection ensure reliability - **Complete Auditability**: Every decision includes reasoning, constitutional citations, and calculation details -- **RAG-based Constitutional Reasoning**: Uses retrieval-augmented generation to understand and apply governance rules -- **Template Flexibility**: Works with diverse governance models (petition, consensus, do-ocracy, jury, circles) - **Platform-Agnostic**: Same governance logic works across Mastodon, Discord, Telegram, Matrix, and more -- **Reversible Actions**: All actions are logged and can be reversed through constitutional processes - **Temporal Awareness**: Handles multi-day governance processes with deadlines and reminders ## Supported Platforms - βœ… **Mastodon** - Full implementation with streaming, admin, and moderation -- 🚧 **Discord** - Coming soon (see [PLATFORMS.md](PLATFORMS.md) for implementation guide) +- βœ… **Slack** - Channel-scoped governance with Socket Mode +- 🚧 **Discord** - Coming soon (see [PLATFORMS.md](PLATFORMS.md)) - 🚧 **Telegram** - Coming soon - 🚧 **Matrix** - Planned -Want to add a platform? See [PLATFORMS.md](PLATFORMS.md) for the implementation guide! - -## Architecture - -``` -β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” -β”‚ Governance Request β”‚ -β”‚ (Natural Language from User) β”‚ -β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ - β”‚ - β–Ό -β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” -β”‚ Governance Agent (LLM) β”‚ -β”‚ β€’ Interprets constitution (RAG) β”‚ -β”‚ β€’ Queries structured memory β”‚ -β”‚ β€’ Uses tools for calculations β”‚ -β”‚ β€’ Makes decisions with reasoning β”‚ -β”‚ β€’ Generates audit trails β”‚ -β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ - β”‚ - β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β” - β”‚ β”‚ β”‚ - β–Ό β–Ό β–Ό - β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β” - β”‚ Memory β”‚ β”‚Tools β”‚ β”‚ Audit β”‚ - β”‚ System β”‚ β”‚ β”‚ β”‚ Trail β”‚ - β””β”€β”€β”€β”€β”¬β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”˜ - β”‚ - β–Ό -β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” -β”‚ Platform Adapter Layer β”‚ -β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ - β”‚ - β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β” - β”‚ β”‚ β”‚ -β”Œβ”€β–Όβ”€β”€β” β”Œβ”€β–Όβ”€β”€β” β”Œβ”€β–Όβ”€β”€β” -β”‚Mastβ”‚β”‚Discβ”‚β”‚Teleβ”‚ -β”‚odonβ”‚β”‚ord β”‚β”‚gramβ”‚ -β””β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”˜ -``` - -For detailed architecture documentation, see [ARCHITECTURE.md](ARCHITECTURE.md). - -## Installation - -```bash -# Install dependencies (using uv for faster installation) -uv pip install -e . - -# For development -uv pip install -e ".[dev]" -``` - ## Quick Start -### 1. Install Dependencies (above) - -### 2. Configure (Credentials Required) +### 1. Install Dependencies ```bash -# Copy the template -cp config/config.example.yaml config/config.yaml +# Using uv (recommended, faster) +curl -LsSf https://astral.sh/uv/install.sh | sh +uv pip install -e . -# Edit with your credentials -nano config/config.yaml +# Or using pip +pip install -e . ``` -**⚠️ IMPORTANT**: `config/config.yaml` contains your secrets and is automatically ignored by git. Never commit this file. +### 2. Configure + +```bash +cp config/config.example.yaml config/config.yaml +nano config/config.yaml # Edit with your settings +``` + +**⚠️ IMPORTANT**: `config/config.yaml` contains secrets and is gitignored. Never commit this file. Configure: -- Platform credentials (Mastodon access tokens, Discord bot tokens, etc.) -- AI model settings (Ollama local models or cloud API keys) -- Constitution path and database location - -For detailed setup instructions: -- **Mastodon**: See [MASTODON_SETUP.md](MASTODON_SETUP.md) -- **Security**: See [SECURITY.md](SECURITY.md) for credential management - -### 3. Set AI API Keys (if using cloud models) +- **Platform credentials** (Mastodon access tokens, etc.) - See [MASTODON_SETUP.md](MASTODON_SETUP.md) +- **AI model** (Ollama local models or cloud API keys) +- **Constitution path** and database location +For cloud models, set API keys: ```bash -# For OpenAI -llm keys set openai - -# For Anthropic Claude -llm keys set anthropic +llm keys set openai # For OpenAI +llm keys set anthropic # For Anthropic Claude ``` -These are stored securely in `~/.llm/keys.json` (also gitignored) - -## Usage +### 3. Run the Bot ```bash -# Run the bot +# Activate virtual environment (REQUIRED) +source .venv/bin/activate + +# Connect to Mastodon (or configured platform) python -m src.govbot.bot +# Test locally without Mastodon +python -m src.govbot + # Query the constitution python -m src.govbot.governance.constitution "What are the rules for proposals?" ``` +## Testing Locally + +Test all governance features without connecting to a platform: + +```bash +source .venv/bin/activate # Activate virtual environment first +python -m src.govbot +``` + +Try these commands: +- `constitution` - View the constitution +- `query What are the rules for creating a proposal?` - Ask constitutional questions +- `propose We should update the guidelines` - Create a test proposal +- `processes` - Check active processes +- `vote 1 agree` - Vote on a process +- `status 1` - Check process status +- `actions` - View recent actions + +## Architecture + +The bot uses an LLM agent that: +1. Interprets natural language constitutions via RAG +2. Queries structured memory for governance state +3. Uses deterministic tools for calculations +4. Makes decisions with full reasoning trails +5. Works across any platform via adapters + +``` +User Request β†’ Governance Agent (LLM) β†’ Platform Action + ↓ + Constitution (RAG) + Memory System + Audit Trail +``` + +For detailed architecture, see [ARCHITECTURE.md](ARCHITECTURE.md) and [ARCHITECTURE_EXAMPLE.md](ARCHITECTURE_EXAMPLE.md) + ## Constitution Format -Your constitution should be a markdown file that describes: +Your constitution is a markdown file describing: - Governance processes (proposals, voting, etc.) -- Decision-making thresholds -- Member rights and responsibilities -- Administrative procedures -- Safety mechanisms (veto, appeals, etc.) +- Decision-making thresholds and member rights +- Administrative procedures and safety mechanisms See `constitution.md` for an example based on Social.coop's bylaws. +### Publishing Constitution + +The bot can publish its constitution as a **pinned Mastodon thread** with automatic version control: + +```bash +# Publish or update constitution +python scripts/publish_constitution.py --summary "What changed" +``` + +When updated, the bot: +- Adds a deprecation notice to the previous version +- Posts the new version as a thread +- Pins it to the bot's profile +- Maintains a public history of all versions + +See [CONSTITUTION_PUBLISHING.md](CONSTITUTION_PUBLISHING.md) for full documentation. + ## Documentation -### Core Documentation -- **[ARCHITECTURE.md](ARCHITECTURE.md)** - System architecture with LLM, memory, tools, and audit trail -- **[ARCHITECTURE_EXAMPLE.md](ARCHITECTURE_EXAMPLE.md)** - Complete walkthrough of a proposal lifecycle -- **[constitution.md](constitution.md)** - Example governance constitution +**Core** +- [ARCHITECTURE.md](ARCHITECTURE.md) - System design and components +- [ARCHITECTURE_EXAMPLE.md](ARCHITECTURE_EXAMPLE.md) - Proposal lifecycle walkthrough +- [constitution.md](constitution.md) - Example governance constitution -### Setup Guides -- **[QUICKSTART.md](QUICKSTART.md)** - Get started quickly with CLI testing -- **[MASTODON_SETUP.md](MASTODON_SETUP.md)** - Complete Mastodon deployment guide -- **[PLATFORMS.md](PLATFORMS.md)** - Guide for implementing new platform adapters -- **[SECURITY.md](SECURITY.md)** - Credential management and security best practices +**Setup** +- [MASTODON_SETUP.md](MASTODON_SETUP.md) - Deploy to Mastodon +- [SLACK_SETUP.md](SLACK_SETUP.md) - Deploy to Slack +- [PLATFORMS.md](PLATFORMS.md) - Add new platform support +- [SECURITY.md](SECURITY.md) - Credential management -### Templates -- **[templates/](templates/)** - Governance template library (petition, consensus, do-ocracy, jury, circles, dispute resolution) +**Templates** +- [templates/](templates/) - Governance models (petition, consensus, do-ocracy, jury, circles) -## Security +## Contributing -⚠️ **Important**: Never commit `config/config.yaml` or other files containing credentials. All sensitive files are automatically protected by `.gitignore`. +Early-stage project. Contributions welcome! -**See [SECURITY.md](SECURITY.md) for:** -- Complete list of protected files -- Where to store credentials -- Best practices for development and production -- What to do if secrets are accidentally committed - -## Development Status - -This is early-stage software. Current phase: Core infrastructure and agentic reasoning engine. +To add platform support: See [PLATFORMS.md](PLATFORMS.md) ## License [To be determined] - -## Contributing - -This project is in early development. Contributions and feedback welcome! - -**For platform developers**: See [PLATFORMS.md](PLATFORMS.md) to add support for Discord, Telegram, Matrix, or other platforms. diff --git a/SLACK_SETUP.md b/SLACK_SETUP.md new file mode 100644 index 0000000..2796926 --- /dev/null +++ b/SLACK_SETUP.md @@ -0,0 +1,629 @@ +# Slack Setup Guide + +This guide walks through setting up Govbot for a Slack channel with channel-scoped governance. + +## Overview + +The Slack adapter enables **channel-scoped governance** where the bot manages a single channel without requiring workspace admin permissions. This makes it ideal for: + +- Department or team governance within larger organizations +- Project-specific decision-making channels +- Community spaces within company Slack workspaces +- Testing governance patterns before workspace-wide deployment + +## Prerequisites + +- Slack workspace (free or paid tier) +- Permission to create Slack apps in the workspace +- Python 3.11+ installed +- Ollama (for local models) or API keys for cloud models + +## Capabilities + +The Slack adapter provides **16 governance skills** across 5 categories: + +### Channel Access Control +- Invite/remove users from the governance channel +- Control who participates in governance + +### Channel Management +- Create new channels for working groups +- Archive/unarchive channels +- Set channel topic and purpose +- Rename channels + +### User Group Management (Channel "Roles") +- Create user groups as governance roles +- Add/remove users from groups +- Enable/disable groups +- Use groups to tag teams (e.g., @moderators, @council) + +### Message Management +- Pin important governance decisions +- Unpin outdated information + +### Content Moderation +- Delete messages (requires user:write scope) + +## Platform Limitations + +**Workspace Admin Only** (not available via channel bot): +- User account management (deactivate, invite new users) +- Workspace settings +- Emoji management +- Billing and plans + +**Not Possible via API**: +- Role/permission management (owners, admins) +- Slack Connect (external organizations) +- Enterprise Grid features +- Workflow management + +The bot is aware of these limitations and will explain them to users when requested. + +## Step 1: Create Slack App + +### 1.1 Create App + +1. Go to [Slack API Dashboard](https://api.slack.com/apps) +2. Click **"Create New App"** +3. Choose **"From scratch"** +4. Enter: + - **App Name**: Govbot + - **Workspace**: Select your workspace +5. Click **"Create App"** + +### 1.2 Configure Bot Token Scopes + +Navigate to **OAuth & Permissions** β†’ **Scopes** β†’ **Bot Token Scopes** + +Add these scopes: + +**Essential (Required)**: +- `app_mentions:read` - Receive mentions of the bot +- `chat:write` - Post messages +- `channels:read` - View basic channel info +- `channels:manage` - Manage channels (rename, archive, etc.) +- `channels:history` - Read message history +- `users:read` - Get user information +- `usergroups:read` - View user groups +- `usergroups:write` - Create and manage user groups +- `pins:write` - Pin/unpin messages + +**Optional (Enhanced Features)**: +- `channels:write` - Create new channels +- `chat:write.public` - Post to channels bot isn't in +- `im:read` - Read direct messages +- `im:write` - Send direct messages +- `im:history` - Read DM history + +**Moderation (If Needed)**: +- `chat:write.customize` - Post as other users (for announcements) +- `users:write` - Modify user accounts (requires admin) + +### 1.3 Enable Socket Mode + +1. Navigate to **Socket Mode** in the sidebar +2. Click **"Enable Socket Mode"** +3. When prompted, create an app-level token: + - **Token Name**: govbot-socket + - **Scopes**: `connections:write` +4. **Save the app token** (starts with `xapp-`) - you'll need this + +### 1.4 Enable Events + +1. Navigate to **Event Subscriptions** +2. Toggle **"Enable Events"** to On +3. Under **Subscribe to bot events**, add: + - `app_mention` - When someone mentions the bot + - `message.im` - Direct messages to the bot +4. Click **"Save Changes"** + +### 1.5 Install App to Workspace + +1. Navigate to **Install App** +2. Click **"Install to Workspace"** +3. Review permissions and click **"Allow"** +4. **Save the Bot User OAuth Token** (starts with `xoxb-`) - you'll need this + +## Step 2: Add Bot to Channel + +### 2.1 Create or Select Governance Channel + +1. Create a new channel (e.g., `#governance`) or select existing channel +2. Invite the bot to the channel: + ``` + /invite @Govbot + ``` + +### 2.2 Get Channel ID + +**Method 1: Via Channel Details** +1. Right-click the channel name +2. Click "View channel details" +3. Scroll to bottom - Channel ID is shown +4. Copy the ID (format: `C0123456789`) + +**Method 2: Via URL** +- When viewing the channel, the URL contains the ID: + `https://app.slack.com/client/T.../C0123456789` + (The part after the last `/` starting with `C`) + +## Step 3: Configure Govbot + +### 3.1 Update Configuration File + +1. **Copy the example config**: + ```bash + cp config/config.example.yaml config/config.yaml + ``` + +2. **Edit `config/config.yaml`**: + ```yaml + platform: + type: slack + + slack: + bot_token: xoxb-your-bot-oauth-token-here + app_token: xapp-your-app-level-token-here + channel_id: C0123456789 # Your governance channel ID + + ai: + # For local models: + default_model: llama3.2 + + # For cloud models: + # default_model: gpt-4o-mini + # openai_api_key: your-key-here + # (or configure with: llm keys set openai) + + governance: + constitution_path: constitution.md + db_path: govbot.db + default_veto_threshold: 0.67 + enable_auto_execution: true + require_confirmation_for: + - admin_action + - moderation + + debug: false + log_level: INFO + ``` + +3. **Test LLM configuration**: + ```bash + # Activate virtual environment + source .venv/bin/activate + + # Test that llm works + llm "Hello, test message" + + # If using local models, verify Ollama is running + ollama list + ``` + +## Step 4: Initialize Database + +```bash +# Activate virtual environment +source .venv/bin/activate + +# Create the database +python -c "from src.govbot.db.models import init_db; init_db('govbot.db'); print('Database initialized!')" +``` + +## Step 5: Test with CLI + +Before connecting to Slack, test the bot logic: + +```bash +# Run the CLI +python -m src.govbot + +# Try these commands: +query What are the rules for proposals? +propose We should have weekly team meetings +processes +exit +``` + +## Step 6: Install Dependencies + +```bash +# Activate virtual environment +source .venv/bin/activate + +# Install/update dependencies (includes slack-sdk) +uv pip install -e . +# Or: pip install -e . +``` + +## Step 7: Run the Bot + +```bash +# Activate virtual environment +source .venv/bin/activate + +# Start the bot +python -m src.govbot.bot + +# You should see: +# INFO - Creating slack platform adapter... +# INFO - Connected to platform successfully +# INFO - Started listening for messages +# INFO - Bot is running. Press Ctrl+C to stop. +``` + +## Step 8: Test on Slack + +### Basic Test + +In your governance channel, type: +``` +@Govbot help +``` + +The bot should respond with information about governance commands. + +### Create a Proposal + +``` +@Govbot I propose we add a weekly team retrospective on Fridays +``` + +The bot should: +- Acknowledge the proposal +- Cite constitutional authority +- Explain voting period and threshold +- Provide voting instructions + +### Vote on Proposal + +In a thread reply or new message: +``` +@Govbot agree +``` + +### Check Status + +``` +@Govbot status 1 +``` + +### Test Platform Skills + +``` +@Govbot what actions can you perform in this channel? +``` + +### Create a User Group (Role) + +``` +@Govbot create a user group called "council" for governance leadership +``` + +## Platform Skills Reference + +### Channel Access +- **invite_to_channel**: Add user to governance channel +- **remove_from_channel**: Remove user from channel + +### Channel Management +- **create_channel**: Create new channel for working groups +- **archive_channel**: Archive a channel +- **unarchive_channel**: Restore archived channel +- **set_channel_topic**: Update channel topic +- **set_channel_purpose**: Update channel description +- **rename_channel**: Change channel name + +### User Groups (Roles) +- **create_user_group**: Create governance role (e.g., @moderators) +- **add_to_user_group**: Add user to role +- **remove_from_user_group**: Remove user from role +- **disable_user_group**: Disable a role + +### Message Management +- **pin_message**: Pin important decisions +- **unpin_message**: Unpin messages +- **delete_message**: Remove inappropriate content + +## Troubleshooting + +### Bot Not Responding + +**Check**: +- Bot is invited to the channel (`/invite @Govbot`) +- Bot token (xoxb-...) is correct +- App token (xapp-...) is correct +- Socket Mode is enabled +- Events are subscribed (`app_mention`, `message.im`) +- Bot process is running (check logs) + +**Test manually**: +```python +from slack_sdk import WebClient + +client = WebClient(token="xoxb-your-token") +response = client.auth_test() +print(f"Bot ID: {response['user_id']}") +print(f"Bot Name: {response['user']}") +``` + +### Socket Mode Connection Errors + +**Check**: +- App token has `connections:write` scope +- No firewall blocking WebSocket connections +- Correct app token (not bot token) + +**Enable debug logging**: +```yaml +debug: true +log_level: DEBUG +``` + +### Permission Errors + +**Common issues**: +- Missing bot token scopes (check OAuth & Permissions) +- Bot not invited to channel +- User attempting action they don't have permission for + +**Verify scopes**: +```python +from slack_sdk import WebClient + +client = WebClient(token="xoxb-your-token") +response = client.auth_test() +print(response) # Shows which scopes are active +``` + +### Channel ID Issues + +**Symptoms**: Bot responds but actions fail + +**Fix**: Verify channel ID +```python +from slack_sdk import WebClient + +client = WebClient(token="xoxb-your-token") +response = client.conversations_list() +for channel in response['channels']: + print(f"{channel['name']}: {channel['id']}") +``` + +### LLM Errors + +**For local models**: +```bash +# Check Ollama is running +ollama list +ollama ps + +# Test the model +llm -m llama3.2 "test" +``` + +**For cloud models**: +```bash +# Check API keys +llm keys list + +# Test the model +llm -m gpt-4o-mini "test" +``` + +### Database Errors + +**Reset database**: +```bash +# Backup first! +cp govbot.db govbot.db.backup + +# Delete and reinitialize +rm govbot.db +python -c "from src.govbot.db.models import init_db; init_db('govbot.db')" +``` + +## Security Considerations + +**πŸ“– See [SECURITY.md](SECURITY.md) for the complete security guide.** + +### Credentials + +- **Never commit** `config/config.yaml` to version control (it's in `.gitignore`) +- Store tokens securely +- Use environment variables for production: + ```bash + export GOVBOT_PLATFORM__SLACK__BOT_TOKEN="xoxb-..." + export GOVBOT_PLATFORM__SLACK__APP_TOKEN="xapp-..." + export GOVBOT_PLATFORM__SLACK__CHANNEL_ID="C0123456789" + ``` +- Rotate tokens periodically via Slack App dashboard +- Use separate apps for development and production + +### Access Control + +- Limit bot scopes to minimum required +- Don't grant unnecessary permissions +- Monitor bot actions through Slack audit logs +- Review user group membership regularly +- Use channel-specific governance (not workspace-wide) + +### Rate Limiting + +Slack has rate limits for API calls: +- **Tier 1**: 1 request per second (most methods) +- **Tier 2**: 20 requests per minute (sending messages) +- **Tier 3**: 50 requests per minute (most read operations) + +The bot handles rate limits automatically with retries, but be aware for high-volume governance. + +## Production Deployment + +### Systemd Service + +Create `/etc/systemd/system/govbot.service`: + +```ini +[Unit] +Description=Govbot Governance Bot (Slack) +After=network.target + +[Service] +Type=simple +User=govbot +WorkingDirectory=/home/govbot/agentic-govbot +ExecStart=/usr/bin/python3 -m src.govbot.bot +Restart=always +RestartSec=10 + +Environment="GOVBOT_PLATFORM__SLACK__BOT_TOKEN=xoxb-..." +Environment="GOVBOT_PLATFORM__SLACK__APP_TOKEN=xapp-..." +Environment="GOVBOT_PLATFORM__SLACK__CHANNEL_ID=C0123456789" +Environment="GOVBOT_LOG_LEVEL=INFO" + +[Install] +WantedBy=multi-user.target +``` + +Enable and start: +```bash +sudo systemctl enable govbot +sudo systemctl start govbot +sudo systemctl status govbot + +# View logs +sudo journalctl -u govbot -f +``` + +### Docker Deployment + +Create `Dockerfile`: +```dockerfile +FROM python:3.11-slim + +WORKDIR /app +COPY . . + +RUN pip install -e . + +CMD ["python", "-m", "src.govbot.bot"] +``` + +Build and run: +```bash +docker build -t govbot . +docker run \ + -e GOVBOT_PLATFORM__SLACK__BOT_TOKEN=xoxb-... \ + -e GOVBOT_PLATFORM__SLACK__APP_TOKEN=xapp-... \ + -e GOVBOT_PLATFORM__SLACK__CHANNEL_ID=C0123456789 \ + -v $(pwd)/config:/app/config \ + -v $(pwd)/govbot.db:/app/govbot.db \ + govbot +``` + +### Monitoring + +- Monitor Socket Mode connection health +- Track API rate limit usage +- Log all governance actions +- Alert on connection failures +- Monitor database growth +- Track user group membership changes + +## Advanced Configuration + +### Multiple Channels + +To manage multiple governance channels, run multiple bot instances: + +```yaml +# config/governance-channel-1.yaml +platform: + type: slack + slack: + bot_token: xoxb-same-token + app_token: xapp-same-token + channel_id: C0111111111 # Different channel + +governance: + db_path: govbot-channel1.db # Separate database +``` + +### User Groups as Roles + +User groups act as channel "roles": + +``` +# Create governance roles +@Govbot create user group "moderators" +@Govbot create user group "council" +@Govbot create user group "working-group-a" + +# Assign members +@Govbot add @alice to moderators +@Govbot add @bob to council + +# Use in governance +"Only @moderators can approve reports" +"@council members have veto power" +``` + +### Custom Constitution + +Edit `constitution.md` to define Slack-specific governance: + +```markdown +## Slack Governance + +### Channel Roles +- **@moderators**: Handle reports and moderation +- **@council**: Make policy decisions +- **Members**: All channel participants + +### Channel Management +New channels require: +1. Proposal with clear purpose +2. 2/3 approval from @council +3. Assignment of channel owner + +### User Groups +- Creation requires council approval +- Membership changes logged publicly +- Groups reviewed quarterly +``` + +## Next Steps + +- Customize the constitution for your team/department +- Test governance workflows with your team +- Create initial user groups (roles) +- Set up announcement patterns +- Document your Slack governance process +- Gather feedback and iterate + +## Getting Help + +- Check [README.md](README.md) for general architecture +- Review [PLATFORMS.md](PLATFORMS.md) for platform details +- See [MASTODON_SETUP.md](MASTODON_SETUP.md) for comparison +- Review [Slack API Documentation](https://api.slack.com/docs) +- Open an issue on GitHub for bugs or questions + +## Comparison: Slack vs Mastodon + +| Feature | Slack | Mastodon | +|---------|-------|----------| +| **Scope** | Channel-scoped | Instance-wide | +| **Admin Powers** | Limited to channel | Full instance admin | +| **Setup** | OAuth app + tokens | OAuth + instance admin | +| **User Management** | User groups only | Full account management | +| **Moderation** | Message deletion | Suspend, silence, ban | +| **Federation** | No | Yes (block domains) | +| **Rate Limits** | Tiered, generous | Per instance config | +| **Best For** | Team/dept governance | Community instance governance | + +--- + +**Important**: This is governance infrastructure for your team. Test thoroughly with a small group before deploying to larger channels! diff --git a/config/config.example.yaml b/config/config.example.yaml index 953776c..2d48442 100644 --- a/config/config.example.yaml +++ b/config/config.example.yaml @@ -1,5 +1,5 @@ platform: - # Platform type: mastodon, discord, telegram, mock + # Platform type: mastodon, slack, discord, telegram, mock type: mastodon # Mastodon configuration (if using Mastodon) @@ -10,6 +10,12 @@ platform: access_token: your_access_token_here bot_username: govbot + # Slack configuration (if using Slack) + # slack: + # bot_token: xoxb-your-bot-oauth-token-here + # app_token: xapp-your-app-level-token-here + # channel_id: C0123456789 + # Discord configuration (for future use) # discord: # token: your_discord_bot_token diff --git a/pyproject.toml b/pyproject.toml index dc11cae..d371209 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,6 +6,7 @@ readme = "README.md" requires-python = ">=3.11" dependencies = [ "mastodon.py>=1.8.0", + "slack-sdk>=3.33.0", "sqlalchemy>=2.0.0", "pydantic>=2.0.0", "pydantic-settings>=2.0.0", diff --git a/requirements.txt b/requirements.txt index 39707a9..aa863bf 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,6 @@ # Core dependencies Mastodon.py>=1.8.0 +slack-sdk>=3.33.0 SQLAlchemy>=2.0.0 llm>=0.13.0 llm-anthropic>=0.23 diff --git a/scripts/publish_constitution.py b/scripts/publish_constitution.py new file mode 100755 index 0000000..d0191b7 --- /dev/null +++ b/scripts/publish_constitution.py @@ -0,0 +1,104 @@ +#!/usr/bin/env python3 +""" +Helper script to publish constitution to Mastodon. + +Usage: + python scripts/publish_constitution.py [--summary "What changed"] +""" + +import sys +import argparse +from pathlib import Path + +# Add src to path +sys.path.insert(0, str(Path(__file__).parent.parent)) + +from src.govbot.utils.config import load_config +from src.govbot.platforms.mastodon import MastodonAdapter + + +def main(): + parser = argparse.ArgumentParser(description="Publish constitution to Mastodon") + parser.add_argument( + "--summary", + "-s", + default="Constitution updated", + help="Summary of changes made" + ) + parser.add_argument( + "--constitution", + "-c", + default="constitution.md", + help="Path to constitution file (default: constitution.md)" + ) + parser.add_argument( + "--config", + default="config/config.yaml", + help="Path to config file (default: config/config.yaml)" + ) + + args = parser.parse_args() + + # Load constitution + constitution_path = Path(args.constitution) + if not constitution_path.exists(): + print(f"❌ Constitution file not found: {constitution_path}") + sys.exit(1) + + constitution_text = constitution_path.read_text() + print(f"πŸ“œ Loaded constitution from {constitution_path} ({len(constitution_text)} chars)") + + # Load config + try: + config = load_config(args.config) + except FileNotFoundError: + print(f"❌ Config file not found: {args.config}") + print(" Copy config/config.example.yaml to config/config.yaml and configure") + sys.exit(1) + + # Connect to Mastodon + print(f"πŸ”Œ Connecting to {config.platform.mastodon.instance_url}...") + + adapter = MastodonAdapter(config.platform.mastodon.model_dump()) + + if not adapter.connect(): + print("❌ Failed to connect to Mastodon") + sys.exit(1) + + print(f"βœ… Connected as @{adapter.bot_username}") + + # Publish constitution + print(f"\nπŸ“€ Publishing constitution...") + print(f" Change summary: {args.summary}") + + result = adapter.execute_skill( + skill_name="publish_constitution", + parameters={ + "constitution_text": constitution_text, + "change_summary": args.summary, + }, + actor="@admin" + ) + + if result["success"]: + print(f"\nβœ… {result['message']}") + print(f"\nπŸ“Š Details:") + print(f" - Thread length: {result['data']['thread_length']} posts") + print(f" - First post ID: {result['data']['first_post_id']}") + if result['data'].get('previous_post_id'): + print(f" - Previous post ID: {result['data']['previous_post_id']} (deprecated)") + + # Generate profile URL + profile_url = f"{adapter.instance_url}/@{adapter.bot_username}" + print(f"\nπŸ”— View at: {profile_url}") + else: + print(f"\n❌ Failed: {result['message']}") + sys.exit(1) + + # Disconnect + adapter.disconnect() + print("\n✨ Done!") + + +if __name__ == "__main__": + main() diff --git a/src/govbot/agent.py b/src/govbot/agent.py index 9bcd1f5..6a131e5 100644 --- a/src/govbot/agent.py +++ b/src/govbot/agent.py @@ -61,7 +61,8 @@ class GovernanceAgent: request: str, actor: str, context: Optional[Dict[str, Any]] = None, - platform_skills: Optional[List[Dict[str, Any]]] = None + platform_skills: Optional[List[Dict[str, Any]]] = None, + platform_limitations: Optional[Dict[str, Any]] = None ) -> Dict[str, Any]: """ Process a governance request using agentic interpretation. @@ -80,6 +81,7 @@ class GovernanceAgent: actor: Who made the request context: Optional context (thread ID, etc.) platform_skills: List of available platform-specific skills + platform_limitations: Information about what's not possible via API Returns: Response dictionary with action taken and audit trail @@ -106,7 +108,8 @@ class GovernanceAgent: memory=memory_context, actor=actor, context=context, - platform_skills=platform_skills + platform_skills=platform_skills, + platform_limitations=platform_limitations ) # Step 5: Execute the decision @@ -225,7 +228,8 @@ Return your analysis as JSON: memory: Dict[str, Any], actor: str, context: Optional[Dict[str, Any]], - platform_skills: Optional[List[Dict[str, Any]]] = None + platform_skills: Optional[List[Dict[str, Any]]] = None, + platform_limitations: Optional[Dict[str, Any]] = None ) -> Dict[str, Any]: """ Use LLM to decide what action to take. @@ -247,10 +251,34 @@ Return your analysis as JSON: platform_skills_info = "\n\nAVAILABLE PLATFORM SKILLS:\n" for skill in platform_skills: params_str = ", ".join([f"{p['name']}: {p['type']}" for p in skill.get('parameters', [])]) + category = skill.get('category', 'general') platform_skills_info += f"- {skill['name']}({params_str}): {skill['description']}\n" + platform_skills_info += f" Category: {category}\n" if skill.get('constitutional_authorization'): platform_skills_info += f" Authorization: {skill['constitutional_authorization']}\n" + # Format platform limitations for the prompt + platform_limitations_info = "" + if platform_limitations: + platform_limitations_info = "\n\nPLATFORM LIMITATIONS:\n" + + if 'web_interface_only' in platform_limitations: + platform_limitations_info += "\nActions requiring web interface (NOT available via bot):\n" + for key, desc in platform_limitations['web_interface_only'].items(): + platform_limitations_info += f"- {key}: {desc}\n" + + if 'not_possible' in platform_limitations: + platform_limitations_info += "\nActions not possible via any API:\n" + for key, desc in platform_limitations['not_possible'].items(): + platform_limitations_info += f"- {key}: {desc}\n" + + if 'limitations' in platform_limitations: + platform_limitations_info += "\nGeneral limitations to be aware of:\n" + for key, desc in platform_limitations['limitations'].items(): + platform_limitations_info += f"- {key}: {desc}\n" + + platform_limitations_info += "\nIMPORTANT: If a user requests something that requires web interface or is not possible via API, explain this limitation clearly and provide the web interface URL if applicable." + prompt = f"""You are a governance bot interpreting a community constitution. IMPORTANT: When generating the "response" field, use newline characters (\\n) for line breaks: @@ -269,7 +297,7 @@ CURRENT MEMORY STATE: {json.dumps(memory, indent=2)} ACTOR: {actor} -{platform_skills_info} +{platform_skills_info}{platform_limitations_info} Based on the constitution and current state, decide what action to take. IMPORTANT AUTHORITY CHECK: diff --git a/src/govbot/bot.py b/src/govbot/bot.py index a7ef0eb..9e37707 100644 --- a/src/govbot/bot.py +++ b/src/govbot/bot.py @@ -25,6 +25,7 @@ from .agent import GovernanceAgent from .scheduler import GovernanceScheduler from .platforms.base import PlatformAdapter, PlatformMessage, MockPlatformAdapter from .platforms.mastodon import MastodonAdapter +from .platforms.slack import SlackAdapter # Configure logging @@ -125,12 +126,14 @@ class Govbot: if platform_type == "mastodon": return MastodonAdapter(self.config.platform.mastodon.model_dump()) + elif platform_type == "slack": + return SlackAdapter(self.config.platform.slack.model_dump()) elif platform_type == "mock": return MockPlatformAdapter({}) else: raise ValueError( f"Unknown platform type: {platform_type}. " - f"Supported: mastodon, mock" + f"Supported: mastodon, slack, mock" ) def run(self): @@ -199,6 +202,7 @@ class Govbot: # Get available platform skills platform_skills_list = [] + platform_limitations = None try: platform_skills = self.platform.get_skills() # Convert PlatformSkill objects to dicts for the agent @@ -214,6 +218,10 @@ class Govbot: "constitutional_authorization": skill.constitutional_authorization, } platform_skills_list.append(skill_dict) + + # Get platform limitations if available + if hasattr(self.platform, 'get_platform_limitations'): + platform_limitations = self.platform.get_platform_limitations() except Exception as e: logger.warning(f"Could not get platform skills: {e}") @@ -222,6 +230,7 @@ class Govbot: actor=f"@{message.author_handle}", context=context, platform_skills=platform_skills_list if platform_skills_list else None, + platform_limitations=platform_limitations, ) # Check if we need to execute a platform skill diff --git a/src/govbot/platforms/mastodon.py b/src/govbot/platforms/mastodon.py index 6968dce..817fecb 100644 --- a/src/govbot/platforms/mastodon.py +++ b/src/govbot/platforms/mastodon.py @@ -279,11 +279,11 @@ class MastodonAdapter(PlatformAdapter): List of available Mastodon governance skills """ return [ - # Moderation skills + # ===== ACCOUNT MODERATION ===== PlatformSkill( name="suspend_account", - description="Suspend a user account (reversible)", - category="moderation", + 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"), @@ -292,10 +292,21 @@ class MastodonAdapter(PlatformAdapter): 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)", - category="moderation", + 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"), @@ -304,10 +315,67 @@ class MastodonAdapter(PlatformAdapter): 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", - category="moderation", + description="Delete a status/post (permanent)", + category="account_moderation", parameters=[ SkillParameter("status_id", "str", "Status ID to delete"), SkillParameter("reason", "str", "Reason for deletion"), @@ -316,11 +384,239 @@ class MastodonAdapter(PlatformAdapter): reversible=False, constitutional_authorization="Requires moderation authority per constitution", ), - # Instance administration skills + + # ===== 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="Update instance rules/code of conduct", - category="admin", + description="[WEB-ONLY] Update instance rules - must be done through admin interface", + category="admin_web_only", parameters=[ SkillParameter("rules", "list", "List of rule texts"), ], @@ -328,44 +624,10 @@ class MastodonAdapter(PlatformAdapter): reversible=True, constitutional_authorization="Requires constitutional amendment process", ), - PlatformSkill( - name="update_instance_description", - description="Update instance description/about page", - category="admin", - parameters=[ - SkillParameter("description", "str", "New instance description"), - ], - requires_confirmation=True, - reversible=True, - constitutional_authorization="Requires governance approval", - ), - PlatformSkill( - name="grant_moderator", - description="Grant moderator role to a user", - category="admin", - parameters=[ - SkillParameter("account_id", "str", "Account ID to promote"), - ], - requires_confirmation=True, - reversible=True, - constitutional_authorization="Requires governance approval", - ), - PlatformSkill( - name="revoke_moderator", - description="Revoke moderator role from a user", - category="admin", - parameters=[ - SkillParameter("account_id", "str", "Account ID to demote"), - ], - requires_confirmation=True, - reversible=True, - constitutional_authorization="Requires governance approval", - ), - # Content management PlatformSkill( name="create_announcement", - description="Create an instance-wide announcement", - category="content", + 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), @@ -377,6 +639,60 @@ class MastodonAdapter(PlatformAdapter): ), ] + 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]: @@ -407,22 +723,78 @@ class MastodonAdapter(PlatformAdapter): # 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 == "update_instance_description": - return self._update_instance_description(parameters) - elif skill_name == "grant_moderator": - return self._grant_moderator(parameters) - elif skill_name == "revoke_moderator": - return self._revoke_moderator(parameters) elif skill_name == "create_announcement": return self._create_announcement(parameters) + else: raise ValueError(f"Unknown skill: {skill_name}") @@ -534,6 +906,8 @@ class MastodonAdapter(PlatformAdapter): # 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"] @@ -550,7 +924,21 @@ class MastodonAdapter(PlatformAdapter): "message": f"Account {account_id} suspended", "data": {"account_id": account_id, "reason": reason}, "reversible": True, - "reverse_params": {"account_id": account_id, "action": "unsuspend"}, + "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]: @@ -569,7 +957,87 @@ class MastodonAdapter(PlatformAdapter): "message": f"Account {account_id} silenced", "data": {"account_id": account_id, "reason": reason}, "reversible": True, - "reverse_params": {"account_id": account_id, "action": "unsilence"}, + "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]: @@ -586,6 +1054,485 @@ class MastodonAdapter(PlatformAdapter): "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", []) @@ -610,51 +1557,6 @@ class MastodonAdapter(PlatformAdapter): "requires_manual_action": True, } - def _update_instance_description(self, params: Dict[str, Any]) -> Dict[str, Any]: - """Update instance description""" - description = params["description"] - - # This would use admin API - # Exact implementation varies - - return { - "success": True, - "message": "Updated instance description", - "data": {"description": description}, - "reversible": True, - "reverse_params": {"description": "previous_description"}, - } - - def _grant_moderator(self, params: Dict[str, Any]) -> Dict[str, Any]: - """Grant moderator role""" - account_id = params["account_id"] - - # Use admin API to update role - self.client.admin_account_moderate(account_id, action="promote_moderator") - - return { - "success": True, - "message": f"Granted moderator to account {account_id}", - "data": {"account_id": account_id}, - "reversible": True, - "reverse_params": {"account_id": account_id}, - } - - def _revoke_moderator(self, params: Dict[str, Any]) -> Dict[str, Any]: - """Revoke moderator role""" - account_id = params["account_id"] - - # Use admin API to update role - self.client.admin_account_moderate(account_id, action="demote_moderator") - - return { - "success": True, - "message": f"Revoked moderator from account {account_id}", - "data": {"account_id": account_id}, - "reversible": True, - "reverse_params": {"account_id": account_id}, - } - def _create_announcement(self, params: Dict[str, Any]) -> Dict[str, Any]: """Create instance announcement - web admin only""" text = params["text"] diff --git a/src/govbot/platforms/slack.py b/src/govbot/platforms/slack.py new file mode 100644 index 0000000..8f08d89 --- /dev/null +++ b/src/govbot/platforms/slack.py @@ -0,0 +1,1071 @@ +""" +Slack platform adapter for Govbot. + +Implements channel-scoped governance for Slack workspaces. + +Features: +--------- +- Channel-level access control (invite/remove users) +- User group management (channel "roles") +- Interactive voting via buttons +- Channel creation and management +- Message pinning +- No workspace admin required for core features + +Configuration Required: +----------------------- +- bot_token: Bot user OAuth token (xoxb-...) +- app_token: App-level token for Socket Mode (xapp-...) +- channel_id: Primary governance channel ID +- signing_secret: For webhook verification + +Getting Tokens: +--------------- +1. Create app at api.slack.com/apps +2. Enable Socket Mode and generate app token +3. Add bot scopes: channels:manage, chat:write, pins:write, reactions:write, + usergroups:write, channels:read, channels:history, users:read +4. Install to workspace and copy bot token + +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 slack_sdk import WebClient + from slack_sdk.socket_mode import SocketModeClient + from slack_sdk.socket_mode.response import SocketModeResponse + from slack_sdk.socket_mode.request import SocketModeRequest + from slack_sdk.errors import SlackApiError + SLACK_AVAILABLE = True +except ImportError: + SLACK_AVAILABLE = False + logging.warning("slack_sdk not installed. Install with: pip install slack-sdk") + +from .base import ( + PlatformAdapter, + PlatformMessage, + PlatformSkill, + SkillParameter, + MessageVisibility, +) + +logger = logging.getLogger("govbot.platforms.slack") + + +class SlackAdapter(PlatformAdapter): + """ + Slack platform adapter implementation. + + Connects to Slack workspaces and provides channel-scoped governance capabilities. + """ + + def __init__(self, config: Dict[str, Any]): + """ + Initialize Slack adapter. + + Args: + config: Configuration dictionary with: + - bot_token: Bot user OAuth token + - app_token: App-level token for Socket Mode + - channel_id: Primary governance channel ID + - signing_secret: Webhook verification secret + """ + super().__init__(config) + + if not SLACK_AVAILABLE: + raise ImportError( + "slack_sdk is required for Slack adapter. " + "Install with: pip install slack-sdk" + ) + + self.bot_token = config.get("bot_token") + self.app_token = config.get("app_token") + self.channel_id = config.get("channel_id") + self.signing_secret = config.get("signing_secret") + + if not self.bot_token: + raise ValueError("Slack adapter requires 'bot_token' in config") + + if not self.app_token: + raise ValueError("Slack adapter requires 'app_token' in config for Socket Mode") + + if not self.channel_id: + raise ValueError("Slack adapter requires 'channel_id' in config") + + self.client: Optional[WebClient] = None + self.socket_client: Optional[SocketModeClient] = None + self.message_callback: Optional[Callable] = None + + def connect(self) -> bool: + """ + Connect to Slack via Socket Mode. + + Returns: + True if connection successful + + Raises: + SlackApiError: If connection fails + """ + try: + logger.info(f"Connecting to Slack...") + + # Initialize web client + self.client = WebClient(token=self.bot_token) + + # Get bot info + auth_response = self.client.auth_test() + self.bot_user_id = auth_response["user_id"] + self.bot_username = auth_response["user"] + + logger.info( + f"Connected as @{self.bot_username} (ID: {self.bot_user_id})" + ) + + # Verify channel access + try: + channel_info = self.client.conversations_info(channel=self.channel_id) + channel_name = channel_info["channel"]["name"] + logger.info(f"Primary governance channel: #{channel_name}") + except SlackApiError as e: + logger.error(f"Cannot access channel {self.channel_id}: {e}") + raise + + self.connected = True + return True + + except Exception as e: + logger.error(f"Failed to connect to Slack: {e}") + raise + + def disconnect(self): + """Disconnect from Slack and cleanup.""" + logger.info("Disconnecting from Slack") + + if self.socket_client: + self.socket_client.close() + self.socket_client = None + + self.connected = False + logger.info("Disconnected from Slack") + + def start_listening(self, callback: Callable[[PlatformMessage], None]): + """ + Start listening for mentions via Socket Mode. + + 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 Slack Socket Mode listener") + + self.message_callback = callback + + # Initialize Socket Mode client + self.socket_client = SocketModeClient( + app_token=self.app_token, + web_client=self.client + ) + + # Register event handlers + self.socket_client.socket_mode_request_listeners.append(self._handle_socket_event) + + # Start listening in a separate thread + def start_socket(): + try: + self.socket_client.connect() + logger.info("Socket Mode listener started") + except Exception as e: + logger.error(f"Socket Mode error: {e}", exc_info=True) + + listener_thread = threading.Thread(target=start_socket, daemon=True) + listener_thread.start() + + logger.info("Started listening for messages") + + def _handle_socket_event(self, client: SocketModeClient, req: SocketModeRequest): + """Handle incoming Socket Mode events""" + + # Acknowledge the request + response = SocketModeResponse(envelope_id=req.envelope_id) + client.send_socket_mode_response(response) + + # Process event + if req.type == "events_api": + event = req.payload.get("event", {}) + event_type = event.get("type") + + if event_type == "app_mention": + self._handle_mention(event) + elif event_type == "message" and event.get("channel_type") == "im": + self._handle_direct_message(event) + + def _handle_mention(self, event: Dict[str, Any]): + """Handle bot mention events""" + # Don't respond to self + if event.get("user") == self.bot_user_id: + return + + # Convert to PlatformMessage + message = self._event_to_message(event) + message.mentions_bot = True + + # Call callback + if self.message_callback: + try: + logger.info(f"Processing mention from @{message.author_handle}") + self.message_callback(message) + except Exception as e: + logger.error(f"Error in message callback: {e}", exc_info=True) + + def _handle_direct_message(self, event: Dict[str, Any]): + """Handle direct message events""" + # Don't respond to self + if event.get("user") == self.bot_user_id: + return + + # Convert to PlatformMessage + message = self._event_to_message(event) + + # Call callback + if self.message_callback: + try: + logger.info(f"Processing DM from @{message.author_handle}") + self.message_callback(message) + except Exception as e: + logger.error(f"Error in message callback: {e}", exc_info=True) + + def _event_to_message(self, event: Dict[str, Any]) -> PlatformMessage: + """Convert Slack event to PlatformMessage""" + + # Get user info + user_id = event.get("user", "") + try: + user_info = self.client.users_info(user=user_id) + username = user_info["user"]["name"] + except: + username = user_id + + # Extract text (remove bot mention) + text = event.get("text", "") + text = text.replace(f"<@{self.bot_user_id}>", "").strip() + + # Determine visibility based on channel type + channel_type = event.get("channel_type", "channel") + if channel_type == "im": + visibility = MessageVisibility.DIRECT + else: + visibility = MessageVisibility.PUBLIC + + # Convert timestamp + ts = event.get("ts", "") + try: + timestamp = datetime.fromtimestamp(float(ts)) + except: + timestamp = datetime.utcnow() + + return PlatformMessage( + id=ts, + text=text, + author_id=user_id, + author_handle=username, + timestamp=timestamp, + thread_id=event.get("thread_ts"), + reply_to_id=event.get("thread_ts"), + visibility=visibility, + raw_data=event, + ) + + 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 Slack. + + Args: + message: Text content to post + thread_id: Thread timestamp to post in + reply_to_id: Message timestamp to reply to + visibility: Ignored for Slack (uses channel context) + + Returns: + Message timestamp + + Raises: + SlackApiError: If posting fails + """ + if not self.connected or not self.client: + raise RuntimeError("Must call connect() before posting") + + try: + response = self.client.chat_postMessage( + channel=self.channel_id, + text=message, + thread_ts=thread_id or reply_to_id, + ) + + logger.info(f"Posted message {response['ts']}") + return response["ts"] + + except Exception as e: + logger.error(f"Failed to post message: {e}") + raise + + def get_skills(self) -> List[PlatformSkill]: + """ + Get Slack channel-scoped skills. + + Returns: + List of available Slack governance skills + """ + return [ + # ===== CHANNEL ACCESS CONTROL ===== + PlatformSkill( + name="invite_to_channel", + description="Invite users to the governance channel", + category="channel_access", + parameters=[ + SkillParameter("user_ids", "list", "List of user IDs to invite"), + ], + requires_confirmation=False, + reversible=True, + constitutional_authorization="Requires membership approval authority", + ), + PlatformSkill( + name="remove_from_channel", + description="Remove user from the governance channel", + category="channel_access", + parameters=[ + SkillParameter("user_id", "str", "User ID to remove"), + SkillParameter("reason", "str", "Reason for removal"), + ], + requires_confirmation=True, + reversible=True, + constitutional_authorization="Requires moderation authority", + ), + + # ===== CHANNEL MANAGEMENT ===== + PlatformSkill( + name="create_channel", + description="Create a new public or private channel", + category="channel_management", + parameters=[ + SkillParameter("name", "str", "Channel name (lowercase, no spaces)"), + SkillParameter("is_private", "bool", "Make channel private", required=False), + ], + requires_confirmation=True, + reversible=True, + constitutional_authorization="Requires channel creation authority", + ), + PlatformSkill( + name="archive_channel", + description="Archive a channel", + category="channel_management", + parameters=[ + SkillParameter("channel_id", "str", "Channel ID to archive"), + ], + requires_confirmation=True, + reversible=True, + constitutional_authorization="Requires channel management authority", + ), + PlatformSkill( + name="unarchive_channel", + description="Unarchive a channel", + category="channel_management", + parameters=[ + SkillParameter("channel_id", "str", "Channel ID to unarchive"), + ], + requires_confirmation=True, + reversible=True, + constitutional_authorization="Requires channel management authority", + ), + PlatformSkill( + name="set_channel_topic", + description="Set channel topic/description", + category="channel_management", + parameters=[ + SkillParameter("topic", "str", "New channel topic"), + SkillParameter("channel_id", "str", "Channel ID (optional, uses governance channel)", required=False), + ], + requires_confirmation=False, + reversible=True, + constitutional_authorization="Requires channel management authority", + ), + PlatformSkill( + name="set_channel_purpose", + description="Set channel purpose", + category="channel_management", + parameters=[ + SkillParameter("purpose", "str", "New channel purpose"), + SkillParameter("channel_id", "str", "Channel ID (optional, uses governance channel)", required=False), + ], + requires_confirmation=False, + reversible=True, + constitutional_authorization="Requires channel management authority", + ), + PlatformSkill( + name="rename_channel", + description="Rename a channel", + category="channel_management", + parameters=[ + SkillParameter("name", "str", "New channel name"), + SkillParameter("channel_id", "str", "Channel ID (optional, uses governance channel)", required=False), + ], + requires_confirmation=True, + reversible=True, + constitutional_authorization="Requires channel management authority", + ), + + # ===== USER GROUP MANAGEMENT (Channel "Roles") ===== + PlatformSkill( + name="create_user_group", + description="Create a user group (channel role)", + category="user_groups", + parameters=[ + SkillParameter("name", "str", "Group name (e.g., 'channel-moderators')"), + SkillParameter("description", "str", "Group description"), + SkillParameter("handle", "str", "Group handle for mentions (e.g., 'mods')"), + ], + requires_confirmation=True, + reversible=True, + constitutional_authorization="Requires role creation authority", + ), + PlatformSkill( + name="add_to_user_group", + description="Add users to a user group", + category="user_groups", + parameters=[ + SkillParameter("group_id", "str", "User group ID"), + SkillParameter("user_ids", "list", "List of user IDs to add"), + ], + requires_confirmation=False, + reversible=True, + constitutional_authorization="Requires role assignment authority", + ), + PlatformSkill( + name="remove_from_user_group", + description="Remove users from a user group", + category="user_groups", + parameters=[ + SkillParameter("group_id", "str", "User group ID"), + SkillParameter("user_ids", "list", "List of user IDs to remove"), + ], + requires_confirmation=False, + reversible=True, + constitutional_authorization="Requires role assignment authority", + ), + PlatformSkill( + name="disable_user_group", + description="Disable a user group", + category="user_groups", + parameters=[ + SkillParameter("group_id", "str", "User group ID to disable"), + ], + requires_confirmation=True, + reversible=True, + constitutional_authorization="Requires role management authority", + ), + + # ===== MESSAGE MANAGEMENT ===== + PlatformSkill( + name="pin_message", + description="Pin a message to the channel", + category="messages", + parameters=[ + SkillParameter("message_ts", "str", "Message timestamp to pin"), + SkillParameter("channel_id", "str", "Channel ID (optional, uses governance channel)", required=False), + ], + requires_confirmation=False, + reversible=True, + constitutional_authorization="Requires message management authority", + ), + PlatformSkill( + name="unpin_message", + description="Unpin a message from the channel", + category="messages", + parameters=[ + SkillParameter("message_ts", "str", "Message timestamp to unpin"), + SkillParameter("channel_id", "str", "Channel ID (optional, uses governance channel)", required=False), + ], + requires_confirmation=False, + reversible=True, + constitutional_authorization="Requires message management authority", + ), + ] + + def get_platform_limitations(self) -> Dict[str, Any]: + """ + Get information about platform limitations. + + Returns: + Dictionary describing what is and isn't possible via API + """ + return { + "available_via_api": { + "channel_access": [ + "invite users to channel", + "remove users from channel (channel-scoped only)", + ], + "channel_management": [ + "create public/private channels", + "archive/unarchive channels", + "set topic and purpose", + "rename channels", + ], + "user_groups": [ + "create user groups (channel 'roles')", + "add/remove users from groups", + "disable groups", + ], + "messages": [ + "pin/unpin messages", + "post with interactive buttons for voting", + ], + }, + "workspace_admin_only": { + "role_management": "True workspace role management (Admin, Owner) requires Enterprise Grid and admin API access", + "workspace_settings": "Workspace-wide settings require web admin interface", + "user_deactivation": "Deactivating users from entire workspace requires admin dashboard", + }, + "not_possible_via_api": { + "workspace_ban": "Cannot ban users from entire workspace via API - only remove from specific channels", + "permission_enforcement": "User groups are labels only - they don't enforce actual Slack permissions", + }, + "limitations": { + "scope": "All governance actions are channel-scoped, not workspace-wide", + "user_groups": "User groups are visible workspace-wide but used for channel governance", + "rate_limits_2025": "Non-Marketplace apps limited to 1 req/min for conversation history (effective May 2025)", + "permissions_required": "Bot requires appropriate scopes: channels:manage, chat:write, pins:write, usergroups:write, etc.", + }, + } + + def execute_skill( + self, skill_name: str, parameters: Dict[str, Any], actor: str + ) -> Dict[str, Any]: + """ + Execute a Slack-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 + SlackApiError: 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: + # Channel access control + if skill_name == "invite_to_channel": + return self._invite_to_channel(parameters) + elif skill_name == "remove_from_channel": + return self._remove_from_channel(parameters) + + # Channel management + elif skill_name == "create_channel": + return self._create_channel(parameters) + elif skill_name == "archive_channel": + return self._archive_channel(parameters) + elif skill_name == "unarchive_channel": + return self._unarchive_channel(parameters) + elif skill_name == "set_channel_topic": + return self._set_channel_topic(parameters) + elif skill_name == "set_channel_purpose": + return self._set_channel_purpose(parameters) + elif skill_name == "rename_channel": + return self._rename_channel(parameters) + + # User group management + elif skill_name == "create_user_group": + return self._create_user_group(parameters) + elif skill_name == "add_to_user_group": + return self._add_to_user_group(parameters) + elif skill_name == "remove_from_user_group": + return self._remove_from_user_group(parameters) + elif skill_name == "disable_user_group": + return self._disable_user_group(parameters) + + # Message management + elif skill_name == "pin_message": + return self._pin_message(parameters) + elif skill_name == "unpin_message": + return self._unpin_message(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 Slack user. + + Args: + user_id: Slack user ID + + Returns: + User info dictionary or None if not found + """ + if not self.connected or not self.client: + return None + + try: + user_info = self.client.users_info(user=user_id) + user = user_info["user"] + + # Check if user is admin/owner + roles = ["member"] + if user.get("is_admin"): + roles.append("admin") + if user.get("is_owner"): + roles.append("owner") + + return { + "id": user["id"], + "handle": user["name"], + "display_name": user.get("real_name", user["name"]), + "roles": roles, + "is_bot": user.get("is_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 Slack thread. + + Args: + thread_id: Message timestamp + + Returns: + Full URL to the message + """ + # Get workspace info + try: + team_info = self.client.team_info() + domain = team_info["team"]["domain"] + + # Format: p + timestamp with no dots + channel ID + ts_clean = thread_id.replace(".", "") + return f"https://{domain}.slack.com/archives/{self.channel_id}/p{ts_clean}" + except: + return f"slack://channel?team=&id={self.channel_id}" + + # ===== CHANNEL ACCESS CONTROL IMPLEMENTATIONS ===== + + def _invite_to_channel(self, params: Dict[str, Any]) -> Dict[str, Any]: + """Invite users to channel""" + user_ids = params["user_ids"] + + try: + self.client.conversations_invite( + channel=self.channel_id, + users=",".join(user_ids) + ) + + return { + "success": True, + "message": f"Invited {len(user_ids)} user(s) to channel", + "data": {"user_ids": user_ids}, + "reversible": True, + "reverse_skill": "remove_from_channel", + } + except SlackApiError as e: + return { + "success": False, + "message": f"Failed to invite users: {e.response['error']}", + "data": {}, + "reversible": False, + } + + def _remove_from_channel(self, params: Dict[str, Any]) -> Dict[str, Any]: + """Remove user from channel""" + user_id = params["user_id"] + reason = params.get("reason", "Removed by governance decision") + + try: + self.client.conversations_kick( + channel=self.channel_id, + user=user_id + ) + + return { + "success": True, + "message": f"Removed user {user_id} from channel", + "data": {"user_id": user_id, "reason": reason}, + "reversible": True, + "reverse_skill": "invite_to_channel", + } + except SlackApiError as e: + return { + "success": False, + "message": f"Failed to remove user: {e.response['error']}", + "data": {}, + "reversible": False, + } + + # ===== CHANNEL MANAGEMENT IMPLEMENTATIONS ===== + + def _create_channel(self, params: Dict[str, Any]) -> Dict[str, Any]: + """Create new channel""" + name = params["name"] + is_private = params.get("is_private", False) + + try: + response = self.client.conversations_create( + name=name, + is_private=is_private + ) + + channel = response["channel"] + + return { + "success": True, + "message": f"Created {'private' if is_private else 'public'} channel #{name}", + "data": { + "channel_id": channel["id"], + "channel_name": channel["name"], + "is_private": is_private, + }, + "reversible": True, + "reverse_skill": "archive_channel", + } + except SlackApiError as e: + return { + "success": False, + "message": f"Failed to create channel: {e.response['error']}", + "data": {}, + "reversible": False, + } + + def _archive_channel(self, params: Dict[str, Any]) -> Dict[str, Any]: + """Archive channel""" + channel_id = params["channel_id"] + + try: + self.client.conversations_archive(channel=channel_id) + + return { + "success": True, + "message": f"Archived channel {channel_id}", + "data": {"channel_id": channel_id}, + "reversible": True, + "reverse_skill": "unarchive_channel", + } + except SlackApiError as e: + return { + "success": False, + "message": f"Failed to archive channel: {e.response['error']}", + "data": {}, + "reversible": False, + } + + def _unarchive_channel(self, params: Dict[str, Any]) -> Dict[str, Any]: + """Unarchive channel""" + channel_id = params["channel_id"] + + try: + self.client.conversations_unarchive(channel=channel_id) + + return { + "success": True, + "message": f"Unarchived channel {channel_id}", + "data": {"channel_id": channel_id}, + "reversible": True, + "reverse_skill": "archive_channel", + } + except SlackApiError as e: + return { + "success": False, + "message": f"Failed to unarchive channel: {e.response['error']}", + "data": {}, + "reversible": False, + } + + def _set_channel_topic(self, params: Dict[str, Any]) -> Dict[str, Any]: + """Set channel topic""" + topic = params["topic"] + channel_id = params.get("channel_id", self.channel_id) + + try: + self.client.conversations_setTopic( + channel=channel_id, + topic=topic + ) + + return { + "success": True, + "message": f"Set channel topic to: {topic}", + "data": {"topic": topic}, + "reversible": True, + } + except SlackApiError as e: + return { + "success": False, + "message": f"Failed to set topic: {e.response['error']}", + "data": {}, + "reversible": False, + } + + def _set_channel_purpose(self, params: Dict[str, Any]) -> Dict[str, Any]: + """Set channel purpose""" + purpose = params["purpose"] + channel_id = params.get("channel_id", self.channel_id) + + try: + self.client.conversations_setPurpose( + channel=channel_id, + purpose=purpose + ) + + return { + "success": True, + "message": f"Set channel purpose to: {purpose}", + "data": {"purpose": purpose}, + "reversible": True, + } + except SlackApiError as e: + return { + "success": False, + "message": f"Failed to set purpose: {e.response['error']}", + "data": {}, + "reversible": False, + } + + def _rename_channel(self, params: Dict[str, Any]) -> Dict[str, Any]: + """Rename channel""" + name = params["name"] + channel_id = params.get("channel_id", self.channel_id) + + try: + self.client.conversations_rename( + channel=channel_id, + name=name + ) + + return { + "success": True, + "message": f"Renamed channel to #{name}", + "data": {"name": name}, + "reversible": True, + } + except SlackApiError as e: + return { + "success": False, + "message": f"Failed to rename channel: {e.response['error']}", + "data": {}, + "reversible": False, + } + + # ===== USER GROUP MANAGEMENT IMPLEMENTATIONS ===== + + def _create_user_group(self, params: Dict[str, Any]) -> Dict[str, Any]: + """Create user group""" + name = params["name"] + description = params["description"] + handle = params["handle"] + + try: + response = self.client.usergroups_create( + name=name, + description=description, + handle=handle + ) + + group = response["usergroup"] + + return { + "success": True, + "message": f"Created user group @{handle}", + "data": { + "group_id": group["id"], + "name": group["name"], + "handle": group["handle"], + }, + "reversible": True, + "reverse_skill": "disable_user_group", + } + except SlackApiError as e: + return { + "success": False, + "message": f"Failed to create user group: {e.response['error']}", + "data": {}, + "reversible": False, + } + + def _add_to_user_group(self, params: Dict[str, Any]) -> Dict[str, Any]: + """Add users to user group""" + group_id = params["group_id"] + user_ids = params["user_ids"] + + try: + # Get current members + group_info = self.client.usergroups_users_list(usergroup=group_id) + current_users = set(group_info.get("users", [])) + + # Add new users + new_users = current_users | set(user_ids) + + # Update group + self.client.usergroups_users_update( + usergroup=group_id, + users=list(new_users) + ) + + return { + "success": True, + "message": f"Added {len(user_ids)} user(s) to group", + "data": {"group_id": group_id, "user_ids": user_ids}, + "reversible": True, + "reverse_skill": "remove_from_user_group", + } + except SlackApiError as e: + return { + "success": False, + "message": f"Failed to add users to group: {e.response['error']}", + "data": {}, + "reversible": False, + } + + def _remove_from_user_group(self, params: Dict[str, Any]) -> Dict[str, Any]: + """Remove users from user group""" + group_id = params["group_id"] + user_ids = params["user_ids"] + + try: + # Get current members + group_info = self.client.usergroups_users_list(usergroup=group_id) + current_users = set(group_info.get("users", [])) + + # Remove users + new_users = current_users - set(user_ids) + + # Update group + self.client.usergroups_users_update( + usergroup=group_id, + users=list(new_users) + ) + + return { + "success": True, + "message": f"Removed {len(user_ids)} user(s) from group", + "data": {"group_id": group_id, "user_ids": user_ids}, + "reversible": True, + "reverse_skill": "add_to_user_group", + } + except SlackApiError as e: + return { + "success": False, + "message": f"Failed to remove users from group: {e.response['error']}", + "data": {}, + "reversible": False, + } + + def _disable_user_group(self, params: Dict[str, Any]) -> Dict[str, Any]: + """Disable user group""" + group_id = params["group_id"] + + try: + self.client.usergroups_disable(usergroup=group_id) + + return { + "success": True, + "message": f"Disabled user group {group_id}", + "data": {"group_id": group_id}, + "reversible": True, + } + except SlackApiError as e: + return { + "success": False, + "message": f"Failed to disable group: {e.response['error']}", + "data": {}, + "reversible": False, + } + + # ===== MESSAGE MANAGEMENT IMPLEMENTATIONS ===== + + def _pin_message(self, params: Dict[str, Any]) -> Dict[str, Any]: + """Pin message to channel""" + message_ts = params["message_ts"] + channel_id = params.get("channel_id", self.channel_id) + + try: + self.client.pins_add( + channel=channel_id, + timestamp=message_ts + ) + + return { + "success": True, + "message": f"Pinned message {message_ts}", + "data": {"message_ts": message_ts}, + "reversible": True, + "reverse_skill": "unpin_message", + } + except SlackApiError as e: + return { + "success": False, + "message": f"Failed to pin message: {e.response['error']}", + "data": {}, + "reversible": False, + } + + def _unpin_message(self, params: Dict[str, Any]) -> Dict[str, Any]: + """Unpin message from channel""" + message_ts = params["message_ts"] + channel_id = params.get("channel_id", self.channel_id) + + try: + self.client.pins_remove( + channel=channel_id, + timestamp=message_ts + ) + + return { + "success": True, + "message": f"Unpinned message {message_ts}", + "data": {"message_ts": message_ts}, + "reversible": True, + "reverse_skill": "pin_message", + } + except SlackApiError as e: + return { + "success": False, + "message": f"Failed to unpin message: {e.response['error']}", + "data": {}, + "reversible": False, + } diff --git a/src/govbot/utils/config.py b/src/govbot/utils/config.py index a55dca2..675db44 100644 --- a/src/govbot/utils/config.py +++ b/src/govbot/utils/config.py @@ -22,6 +22,15 @@ class MastodonConfig(BaseModel): bot_username: str = Field("govbot", description="Bot's Mastodon username") +class SlackConfig(BaseModel): + """Slack workspace configuration""" + + bot_token: str = Field(..., description="Slack bot user OAuth token (xoxb-...)") + app_token: str = Field(..., description="Slack app-level token for Socket Mode (xapp-...)") + channel_id: str = Field(..., description="Channel ID for governance (e.g., C0123456789)") + bot_user_id: Optional[str] = Field(None, description="Bot user ID (will be auto-detected)") + + class AIConfig(BaseModel): """AI model configuration""" @@ -58,8 +67,9 @@ class GovernanceConfig(BaseModel): class PlatformConfig(BaseModel): """Platform selection and configuration""" - type: str = Field(..., description="Platform type: mastodon, discord, telegram, mock") + type: str = Field(..., description="Platform type: mastodon, slack, discord, telegram, mock") mastodon: Optional[MastodonConfig] = Field(None, description="Mastodon configuration") + slack: Optional[SlackConfig] = Field(None, description="Slack configuration") # Future platforms: # discord: Optional[DiscordConfig] = None # telegram: Optional[TelegramConfig] = None @@ -117,7 +127,7 @@ def create_example_config(output_path: str = "config/config.example.yaml"): """ example_config = { "platform": { - "type": "mastodon", # or "discord", "telegram", "mock" + "type": "mastodon", # or "slack", "discord", "telegram", "mock" "mastodon": { "instance_url": "https://your-mastodon-instance.social", "client_id": "your_client_id_here", @@ -125,6 +135,12 @@ def create_example_config(output_path: str = "config/config.example.yaml"): "access_token": "your_access_token_here", "bot_username": "govbot", }, + # Slack example: + # "slack": { + # "bot_token": "xoxb-your-bot-token-here", + # "app_token": "xapp-your-app-token-here", + # "channel_id": "C0123456789", + # }, # Discord example (for future use): # "discord": { # "token": "your_discord_bot_token",