this repo has no description
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

Add enhanced Zulip bot conversation options and configuration

- Add username claiming functionality (@mention claim <username>)
- Add schedule query feature (@mention schedule)
- Add configurable RSS polling parameters
- Add botrc configuration file with defaults for bot behavior
- Add persistent storage for bot settings in Zulip
- Add notification system for config changes and username claims
- Create secure template files for bot configuration
- Update .gitignore to exclude secret configuration files
- Add comprehensive setup documentation

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>

+800 -342
+4
.gitignore
··· 203 203 .streamlit/secrets.toml 204 204 205 205 thicket.yaml 206 + 207 + # Bot configuration files with secrets 206 208 bot-config/zuliprc 209 + bot-config/*.key 210 + bot-config/*.secret
+97
bot-config/README.md
··· 1 + # Thicket Bot Configuration 2 + 3 + This directory contains configuration files for the Thicket Zulip bot. 4 + 5 + ## Setup Instructions 6 + 7 + ### 1. Zulip Bot Configuration 8 + 9 + 1. Copy `zuliprc.template` to `zuliprc`: 10 + ```bash 11 + cp bot-config/zuliprc.template bot-config/zuliprc 12 + ``` 13 + 14 + 2. Create a bot in your Zulip organization: 15 + - Go to Settings > Your bots > Add a new bot 16 + - Choose "Generic bot" type 17 + - Give it a name like "Thicket" and username like "thicket" 18 + - Copy the bot's email and API key 19 + 20 + 3. Edit `bot-config/zuliprc` with your bot's credentials: 21 + ```ini 22 + [api] 23 + email=thicket-bot@your-org.zulipchat.com 24 + key=your-actual-api-key-here 25 + site=https://your-org.zulipchat.com 26 + ``` 27 + 28 + ### 2. Bot Behavior Configuration (Optional) 29 + 30 + 1. Copy `botrc.template` to `botrc` to customize bot behavior: 31 + ```bash 32 + cp bot-config/botrc.template bot-config/botrc 33 + ``` 34 + 35 + 2. Edit `bot-config/botrc` to customize: 36 + - Sync intervals and batch sizes 37 + - Default stream/topic settings 38 + - Rate limiting parameters 39 + - Notification preferences 40 + 41 + **Note**: The bot will work with default settings if no `botrc` file exists. 42 + 43 + ## File Descriptions 44 + 45 + ### `zuliprc` (Required) 46 + Contains Zulip API credentials for the bot. This file should **never** be committed to version control. 47 + 48 + ### `botrc` (Optional) 49 + Contains bot behavior configuration and defaults. This file can be committed to version control as it contains no secrets. 50 + 51 + ### Template Files 52 + - `zuliprc.template` - Template for Zulip credentials 53 + - `botrc.template` - Template for bot behavior settings 54 + 55 + ## Running the Bot 56 + 57 + Once configured, run the bot with: 58 + 59 + ```bash 60 + # Run in foreground 61 + thicket bot run 62 + 63 + # Run in background (daemon mode) 64 + thicket bot run --daemon 65 + 66 + # Debug mode (sends DMs instead of stream posts) 67 + thicket bot run --debug-user your-thicket-username 68 + 69 + # Custom config paths 70 + thicket bot run --config bot-config/zuliprc --botrc bot-config/botrc 71 + ``` 72 + 73 + ## Bot Commands 74 + 75 + Once running, interact with the bot in Zulip: 76 + 77 + - `@thicket help` - Show available commands 78 + - `@thicket status` - Show bot status and configuration 79 + - `@thicket sync now` - Force immediate sync 80 + - `@thicket schedule` - Show sync schedule 81 + - `@thicket claim <username>` - Claim a thicket username 82 + - `@thicket config <setting> <value>` - Change bot settings 83 + 84 + ## Security Notes 85 + 86 + - **Never commit `zuliprc` with real credentials** 87 + - Add `bot-config/zuliprc` to `.gitignore` 88 + - The `botrc` file contains no secrets and can be safely committed 89 + - Bot settings changed via chat are stored in Zulip's persistent storage 90 + 91 + ## Troubleshooting 92 + 93 + - Check bot status: `thicket bot status` 94 + - View bot logs when running in foreground mode 95 + - Verify Zulip credentials are correct 96 + - Ensure thicket.yaml configuration exists 97 + - Test bot functionality: `thicket bot test`
+28
bot-config/botrc
··· 1 + [bot] 2 + # Default RSS feed polling interval in seconds (minimum 60) 3 + sync_interval = 300 4 + 5 + # Maximum number of entries to post per sync cycle 6 + max_entries_per_sync = 10 7 + 8 + # Default stream and topic for posting (can be overridden via chat commands) 9 + # Leave empty to require configuration via chat 10 + default_stream = 11 + default_topic = 12 + 13 + # Rate limiting: seconds to wait between batches of posts 14 + rate_limit_delay = 5 15 + 16 + # Number of posts per batch before applying rate limit 17 + posts_per_batch = 5 18 + 19 + [catchup] 20 + # Number of entries to post on first run (catchup mode) 21 + catchup_entries = 5 22 + 23 + [notifications] 24 + # Whether to send notifications when bot configuration changes 25 + config_change_notifications = true 26 + 27 + # Whether to send notifications when users claim usernames 28 + username_claim_notifications = true
+34
bot-config/botrc.template
··· 1 + [bot] 2 + # Default RSS feed polling interval in seconds (minimum 60) 3 + sync_interval = 300 4 + 5 + # Maximum number of entries to post per sync cycle (1-50) 6 + max_entries_per_sync = 10 7 + 8 + # Default stream and topic for posting (can be overridden via chat commands) 9 + # Leave empty to require configuration via chat 10 + default_stream = 11 + default_topic = 12 + 13 + # Rate limiting: seconds to wait between batches of posts 14 + rate_limit_delay = 5 15 + 16 + # Number of posts per batch before applying rate limit 17 + posts_per_batch = 5 18 + 19 + [catchup] 20 + # Number of entries to post on first run (catchup mode) 21 + catchup_entries = 5 22 + 23 + [notifications] 24 + # Whether to send notifications when bot configuration changes 25 + config_change_notifications = true 26 + 27 + # Whether to send notifications when users claim usernames 28 + username_claim_notifications = true 29 + 30 + # Instructions: 31 + # 1. Copy this file to botrc (without .template extension) to customize bot behavior 32 + # 2. The bot will use these defaults if no botrc file is found 33 + # 3. All settings can be overridden via chat commands (e.g., @mention config interval 600) 34 + # 4. Settings changed via chat are persisted in Zulip storage and take precedence
+16
bot-config/zuliprc.template
··· 1 + [api] 2 + # Your bot's email address (create this in Zulip Settings > Bots) 3 + email=your-bot@your-organization.zulipchat.com 4 + 5 + # Your bot's API key (found in Zulip Settings > Bots) 6 + key=YOUR_BOT_API_KEY_HERE 7 + 8 + # Your Zulip server URL 9 + site=https://your-organization.zulipchat.com 10 + 11 + # Instructions: 12 + # 1. Copy this file to zuliprc (without .template extension) 13 + # 2. Replace the placeholder values with your actual bot credentials 14 + # 3. Create a bot in your Zulip organization at Settings > Bots 15 + # 4. Use the bot's email and API key from the Zulip interface 16 + # 5. Never commit the actual zuliprc file with real credentials to version control
+1 -1
src/thicket/bots/__init__.py
··· 2 2 3 3 from .thicket_bot import ThicketBotHandler 4 4 5 - __all__ = ["ThicketBotHandler"] 5 + __all__ = ["ThicketBotHandler"]
+38 -38
src/thicket/bots/test_bot.py
··· 3 3 import json 4 4 from pathlib import Path 5 5 from typing import Any, Dict, Optional 6 - from unittest.mock import Mock 7 6 8 - from ..models import AtomEntry, ThicketConfig 7 + from ..models import AtomEntry 9 8 from .thicket_bot import ThicketBotHandler 10 9 11 10 12 11 class MockBotHandler: 13 12 """Mock BotHandler for testing the Thicket bot.""" 14 - 13 + 15 14 def __init__(self) -> None: 16 15 """Initialize mock bot handler.""" 17 16 self.storage_data: Dict[str, str] = {} ··· 20 19 "full_name": "Thicket Bot", 21 20 "email": "thicket-bot@example.com" 22 21 } 23 - 22 + 24 23 def get_config_info(self) -> Dict[str, str]: 25 24 """Return bot configuration info.""" 26 25 return self.config_info 27 - 26 + 28 27 def send_reply(self, message: Dict[str, Any], content: str) -> None: 29 28 """Mock sending a reply.""" 30 29 reply = { ··· 34 33 "original_message": message 35 34 } 36 35 self.sent_messages.append(reply) 37 - 36 + 38 37 def send_message(self, message: Dict[str, Any]) -> None: 39 38 """Mock sending a message.""" 40 39 self.sent_messages.append(message) 41 - 40 + 42 41 @property 43 42 def storage(self) -> 'MockStorage': 44 43 """Return mock storage.""" ··· 47 46 48 47 class MockStorage: 49 48 """Mock storage for bot state.""" 50 - 49 + 51 50 def __init__(self, storage_data: Dict[str, str]) -> None: 52 51 """Initialize with storage data.""" 53 52 self.storage_data = storage_data 54 - 53 + 55 54 def __enter__(self) -> 'MockStorage': 56 55 """Context manager entry.""" 57 56 return self 58 - 57 + 59 58 def __exit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None: 60 59 """Context manager exit.""" 61 60 pass 62 - 61 + 63 62 def get(self, key: str) -> Optional[str]: 64 63 """Get value from storage.""" 65 64 return self.storage_data.get(key) 66 - 65 + 67 66 def put(self, key: str, value: str) -> None: 68 67 """Put value in storage.""" 69 68 self.storage_data[key] = value 70 - 69 + 71 70 def contains(self, key: str) -> bool: 72 71 """Check if key exists in storage.""" 73 72 return key in self.storage_data 74 73 75 74 76 75 def create_test_message( 77 - content: str, 76 + content: str, 78 77 sender: str = "Test User", 79 78 sender_id: int = 12345, 80 79 message_type: str = "stream" ··· 98 97 ) -> AtomEntry: 99 98 """Create a test AtomEntry for testing.""" 100 99 from datetime import datetime 100 + 101 101 from pydantic import HttpUrl 102 - 102 + 103 103 return AtomEntry( 104 104 id=entry_id, 105 105 title=title, ··· 114 114 115 115 class BotTester: 116 116 """Helper class for testing bot functionality.""" 117 - 117 + 118 118 def __init__(self, config_path: Optional[Path] = None) -> None: 119 119 """Initialize bot tester.""" 120 120 self.bot = ThicketBotHandler() 121 121 self.handler = MockBotHandler() 122 - 122 + 123 123 if config_path: 124 124 # Configure bot with test config 125 125 self.configure_bot(config_path, "test-stream", "test-topic") 126 - 126 + 127 127 def configure_bot( 128 - self, 129 - config_path: Path, 130 - stream: str = "test-stream", 128 + self, 129 + config_path: Path, 130 + stream: str = "test-stream", 131 131 topic: str = "test-topic" 132 132 ) -> None: 133 133 """Configure the bot for testing.""" 134 134 # Set bot configuration 135 135 config_data = { 136 136 "stream_name": stream, 137 - "topic_name": topic, 137 + "topic_name": topic, 138 138 "sync_interval": 300, 139 139 "max_entries_per_sync": 10, 140 140 "config_path": str(config_path) 141 141 } 142 - 142 + 143 143 self.handler.storage_data["bot_config"] = json.dumps(config_data) 144 - 144 + 145 145 # Initialize bot 146 146 self.bot._load_bot_config(self.handler) 147 - 147 + 148 148 def send_command(self, command: str, sender: str = "Test User") -> list[Dict[str, Any]]: 149 149 """Send a command to the bot and return responses.""" 150 150 message = create_test_message(f"@thicket {command}", sender) 151 - 151 + 152 152 # Clear previous messages 153 153 self.handler.sent_messages.clear() 154 - 154 + 155 155 # Send command 156 156 self.bot.handle_message(message, self.handler) 157 - 157 + 158 158 return self.handler.sent_messages.copy() 159 - 159 + 160 160 def get_last_response_content(self) -> Optional[str]: 161 161 """Get the content of the last bot response.""" 162 162 if self.handler.sent_messages: 163 163 return self.handler.sent_messages[-1].get("content") 164 164 return None 165 - 165 + 166 166 def get_last_message(self) -> Optional[Dict[str, Any]]: 167 167 """Get the last sent message.""" 168 168 if self.handler.sent_messages: 169 169 return self.handler.sent_messages[-1] 170 170 return None 171 - 171 + 172 172 def assert_response_contains(self, text: str) -> None: 173 173 """Assert that the last response contains specific text.""" 174 174 content = self.get_last_response_content() ··· 180 180 if __name__ == "__main__": 181 181 # Create a test config file 182 182 test_config = Path("/tmp/test_thicket.yaml") 183 - 183 + 184 184 # Create bot tester 185 185 tester = BotTester() 186 - 186 + 187 187 # Test help command 188 188 responses = tester.send_command("help") 189 189 print(f"Help response: {tester.get_last_response_content()}") 190 - 190 + 191 191 # Test status command 192 192 responses = tester.send_command("status") 193 193 print(f"Status response: {tester.get_last_response_content()}") 194 - 194 + 195 195 # Test configuration 196 196 responses = tester.send_command("config stream general") 197 197 tester.assert_response_contains("Stream set to") 198 - 198 + 199 199 responses = tester.send_command("config topic 'Feed Updates'") 200 200 tester.assert_response_contains("Topic set to") 201 - 202 - print("All tests passed!") 201 + 202 + print("All tests passed!")
+398 -137
src/thicket/bots/thicket_bot.py
··· 5 5 import logging 6 6 import os 7 7 import time 8 - from datetime import datetime 9 8 from pathlib import Path 10 - from typing import Any, Dict, List, Optional, Set, Tuple 9 + from typing import Any, Optional 11 10 12 11 from zulip_bots.lib import BotHandler 13 12 14 13 # Handle imports for both direct execution and package import 15 14 try: 15 + from ..cli.commands.sync import sync_feed 16 16 from ..core.git_store import GitStore 17 17 from ..models import AtomEntry, ThicketConfig 18 - from ..cli.commands.sync import sync_feed 19 18 except ImportError: 20 19 # When run directly by zulip-bots, add the package to path 21 20 import sys 22 21 src_dir = Path(__file__).parent.parent.parent 23 22 if str(src_dir) not in sys.path: 24 23 sys.path.insert(0, str(src_dir)) 25 - 24 + 25 + from thicket.cli.commands.sync import sync_feed 26 26 from thicket.core.git_store import GitStore 27 27 from thicket.models import AtomEntry, ThicketConfig 28 - from thicket.cli.commands.sync import sync_feed 29 28 30 29 31 30 class ThicketBotHandler: ··· 36 35 self.logger = logging.getLogger(__name__) 37 36 self.git_store: Optional[GitStore] = None 38 37 self.config: Optional[ThicketConfig] = None 39 - self.posted_entries: Set[str] = set() 40 - 38 + self.posted_entries: set[str] = set() 39 + 41 40 # Bot configuration from storage 42 41 self.stream_name: Optional[str] = None 43 42 self.topic_name: Optional[str] = None 44 43 self.sync_interval: int = 300 # 5 minutes default 45 44 self.max_entries_per_sync: int = 10 46 45 self.config_path: Optional[Path] = None 47 - 46 + 47 + # Bot behavior settings (loaded from botrc) 48 + self.rate_limit_delay: int = 5 49 + self.posts_per_batch: int = 5 50 + self.catchup_entries: int = 5 51 + self.config_change_notifications: bool = True 52 + self.username_claim_notifications: bool = True 53 + 54 + # Track last sync time for schedule queries 55 + self.last_sync_time: Optional[float] = None 56 + 48 57 # Debug mode configuration 49 58 self.debug_user: Optional[str] = None 50 59 self.debug_zulip_user_id: Optional[str] = None 51 - 60 + 52 61 def usage(self) -> str: 53 62 """Return bot usage instructions.""" 54 63 return """ 55 64 **Thicket Feed Bot** 56 - 65 + 57 66 This bot automatically monitors thicket feeds and posts new articles. 58 - 67 + 59 68 Commands: 60 - - `@mention status` - Show current bot status and configuration 69 + - `@mention status` - Show current bot status and configuration 61 70 - `@mention sync now` - Force an immediate sync 62 71 - `@mention reset` - Clear posting history (will repost recent entries) 63 72 - `@mention config stream <stream_name>` - Set target stream 64 - - `@mention config topic <topic_name>` - Set target topic 73 + - `@mention config topic <topic_name>` - Set target topic 65 74 - `@mention config interval <seconds>` - Set sync interval 75 + - `@mention schedule` - Show sync schedule and next run time 76 + - `@mention claim <username>` - Claim a thicket username for your Zulip account 66 77 - `@mention help` - Show this help message 67 78 """ 68 79 69 80 def initialize(self, bot_handler: BotHandler) -> None: 70 81 """Initialize the bot with persistent storage.""" 71 82 self.logger.info("Initializing ThicketBot") 72 - 83 + 73 84 # Get configuration from environment (set by CLI) 74 85 self.debug_user = os.getenv("THICKET_DEBUG_USER") 75 86 config_path_env = os.getenv("THICKET_CONFIG_PATH") 76 87 if config_path_env: 77 88 self.config_path = Path(config_path_env) 78 89 self.logger.info(f"Using thicket config: {self.config_path}") 79 - 90 + 91 + # Load default configuration from botrc file 92 + self._load_botrc_defaults() 93 + 80 94 # Load bot configuration from persistent storage 81 95 self._load_bot_config(bot_handler) 82 - 96 + 83 97 # Initialize thicket components 84 98 if self.config_path: 85 99 try: 86 100 self._initialize_thicket() 87 101 self._load_posted_entries(bot_handler) 88 - 102 + 89 103 # Validate debug mode if enabled 90 104 if self.debug_user: 91 105 self._validate_debug_mode(bot_handler) 92 - 106 + 93 107 except Exception as e: 94 108 self.logger.error(f"Failed to initialize thicket: {e}") 95 - 109 + 96 110 # Start background sync loop 97 111 self._schedule_sync(bot_handler) 98 112 99 - def handle_message(self, message: Dict[str, Any], bot_handler: BotHandler) -> None: 113 + def handle_message(self, message: dict[str, Any], bot_handler: BotHandler) -> None: 100 114 """Handle incoming Zulip messages.""" 101 115 content = message["content"].strip() 102 116 sender = message["sender_full_name"] 103 - 117 + 104 118 # Only respond to mentions 105 119 if not self._is_mentioned(content, bot_handler): 106 120 return 107 - 121 + 108 122 # Parse command 109 123 cleaned_content = self._clean_mention(content, bot_handler) 110 124 command_parts = cleaned_content.split() 111 - 125 + 112 126 if not command_parts: 113 127 self._send_help(message, bot_handler) 114 128 return 115 - 129 + 116 130 command = command_parts[0].lower() 117 - 131 + 118 132 try: 119 133 if command == "help": 120 134 self._send_help(message, bot_handler) ··· 126 140 self._handle_reset_command(message, bot_handler, sender) 127 141 elif command == "config": 128 142 self._handle_config_command(message, bot_handler, command_parts[1:], sender) 143 + elif command == "schedule": 144 + self._handle_schedule_command(message, bot_handler, sender) 145 + elif command == "claim": 146 + self._handle_claim_command(message, bot_handler, command_parts[1:], sender) 129 147 else: 130 148 bot_handler.send_reply(message, f"Unknown command: {command}. Type `@mention help` for usage.") 131 149 except Exception as e: ··· 143 161 return f"@{bot_name}" in content.lower() or f"@**{bot_name}**" in content.lower() 144 162 except Exception as e: 145 163 self.logger.debug(f"Could not get bot profile: {e}") 146 - 164 + 147 165 # Fallback to generic check 148 166 return "@thicket" in content.lower() 149 - 167 + 150 168 def _clean_mention(self, content: str, bot_handler: BotHandler) -> str: 151 169 """Remove bot mention from message content.""" 152 170 import re 153 - 171 + 154 172 try: 155 173 # Get bot's actual name from Zulip 156 174 bot_info = bot_handler._client.get_profile() ··· 163 181 return content 164 182 except Exception as e: 165 183 self.logger.debug(f"Could not get bot profile for mention cleaning: {e}") 166 - 184 + 167 185 # Fallback to removing @thicket 168 186 content = re.sub(r'@(?:\*\*)?thicket(?:\*\*)?', '', content, flags=re.IGNORECASE).strip() 169 187 return content 170 188 171 - def _send_help(self, message: Dict[str, Any], bot_handler: BotHandler) -> None: 189 + def _send_help(self, message: dict[str, Any], bot_handler: BotHandler) -> None: 172 190 """Send help message.""" 173 191 bot_handler.send_reply(message, self.usage()) 174 192 175 - def _send_status(self, message: Dict[str, Any], bot_handler: BotHandler, sender: str) -> None: 193 + def _send_status(self, message: dict[str, Any], bot_handler: BotHandler, sender: str) -> None: 176 194 """Send bot status information.""" 177 195 status_lines = [ 178 196 f"**Thicket Bot Status** (requested by {sender})", 179 197 "", 180 198 ] 181 - 199 + 182 200 # Debug mode status 183 201 if self.debug_user: 184 202 status_lines.extend([ 185 - f"🐛 **Debug Mode:** ENABLED", 203 + "🐛 **Debug Mode:** ENABLED", 186 204 f"🎯 **Debug User:** {self.debug_user}", 187 205 "", 188 206 ]) ··· 192 210 f"📝 **Topic:** {self.topic_name or 'Not configured'}", 193 211 "", 194 212 ]) 195 - 213 + 196 214 status_lines.extend([ 197 215 f"⏱️ **Sync Interval:** {self.sync_interval}s ({self.sync_interval // 60}m {self.sync_interval % 60}s)", 198 216 f"📊 **Max Entries/Sync:** {self.max_entries_per_sync}", ··· 201 219 f"📄 **Tracked Entries:** {len(self.posted_entries)}", 202 220 f"🔄 **Catchup Mode:** {'Active (first run)' if len(self.posted_entries) == 0 else 'Inactive'}", 203 221 f"✅ **Thicket Initialized:** {'Yes' if self.git_store else 'No'}", 222 + "", 223 + self._get_schedule_info(), 204 224 ]) 205 - 225 + 206 226 bot_handler.send_reply(message, "\n".join(status_lines)) 207 227 208 - def _handle_force_sync(self, message: Dict[str, Any], bot_handler: BotHandler, sender: str) -> None: 228 + def _handle_force_sync(self, message: dict[str, Any], bot_handler: BotHandler, sender: str) -> None: 209 229 """Handle immediate sync request.""" 210 230 if not self._check_initialization(message, bot_handler): 211 231 return 212 - 232 + 213 233 bot_handler.send_reply(message, f"🔄 Starting immediate sync... (requested by {sender})") 214 - 234 + 215 235 try: 216 236 new_entries = self._perform_sync(bot_handler) 217 237 bot_handler.send_reply( 218 - message, 238 + message, 219 239 f"✅ Sync completed! Found {len(new_entries)} new entries." 220 240 ) 221 241 except Exception as e: 222 242 self.logger.error(f"Force sync failed: {e}") 223 243 bot_handler.send_reply(message, f"❌ Sync failed: {str(e)}") 224 244 225 - def _handle_reset_command(self, message: Dict[str, Any], bot_handler: BotHandler, sender: str) -> None: 245 + def _handle_reset_command(self, message: dict[str, Any], bot_handler: BotHandler, sender: str) -> None: 226 246 """Handle reset command to clear posted entries tracking.""" 227 247 try: 228 248 self.posted_entries.clear() ··· 236 256 self.logger.error(f"Reset failed: {e}") 237 257 bot_handler.send_reply(message, f"❌ Reset failed: {str(e)}") 238 258 259 + def _handle_schedule_command(self, message: dict[str, Any], bot_handler: BotHandler, sender: str) -> None: 260 + """Handle schedule query command.""" 261 + schedule_info = self._get_schedule_info() 262 + bot_handler.send_reply( 263 + message, 264 + f"**Thicket Bot Schedule** (requested by {sender})\n\n{schedule_info}" 265 + ) 266 + 267 + def _handle_claim_command( 268 + self, 269 + message: dict[str, Any], 270 + bot_handler: BotHandler, 271 + args: list[str], 272 + sender: str 273 + ) -> None: 274 + """Handle username claiming command.""" 275 + if not args: 276 + bot_handler.send_reply(message, "Usage: `@mention claim <username>`") 277 + return 278 + 279 + if not self._check_initialization(message, bot_handler): 280 + return 281 + 282 + username = args[0].strip() 283 + 284 + # Get sender's Zulip user info 285 + sender_user_id = message.get("sender_id") 286 + sender_email = message.get("sender_email") 287 + 288 + if not sender_user_id or not sender_email: 289 + bot_handler.send_reply(message, "❌ Could not determine your Zulip user information.") 290 + return 291 + 292 + try: 293 + # Get current Zulip server from environment 294 + zulip_site_url = os.getenv("THICKET_ZULIP_SITE_URL", "") 295 + server_url = zulip_site_url.replace("https://", "").replace("http://", "") 296 + 297 + if not server_url: 298 + bot_handler.send_reply(message, "❌ Could not determine Zulip server URL.") 299 + return 300 + 301 + # Check if username exists in thicket 302 + user = self.git_store.get_user(username) 303 + if not user: 304 + bot_handler.send_reply( 305 + message, 306 + f"❌ Username `{username}` not found in thicket. Available users: {', '.join(self.git_store.list_users())}" 307 + ) 308 + return 309 + 310 + # Check if username is already claimed for this server 311 + existing_zulip_id = user.get_zulip_mention(server_url) 312 + if existing_zulip_id: 313 + # Check if it's claimed by the same user 314 + if existing_zulip_id == sender_email or str(existing_zulip_id) == str(sender_user_id): 315 + bot_handler.send_reply( 316 + message, 317 + f"✅ Username `{username}` is already claimed by you on {server_url}!" 318 + ) 319 + else: 320 + bot_handler.send_reply( 321 + message, 322 + f"❌ Username `{username}` is already claimed by another user on {server_url}." 323 + ) 324 + return 325 + 326 + # Claim the username - prefer email for consistency 327 + success = self.git_store.add_zulip_association(username, server_url, sender_email) 328 + 329 + if success: 330 + reply_msg = f"🎉 Successfully claimed username `{username}` for **{sender}** on {server_url}!\n" + \ 331 + "You will now be mentioned when new articles are posted from this user's feeds." 332 + bot_handler.send_reply(message, reply_msg) 333 + 334 + # Send notification to configured stream if enabled and not in debug mode 335 + if (self.username_claim_notifications and 336 + not self.debug_user and 337 + self.stream_name and self.topic_name): 338 + try: 339 + notification_msg = f"👋 **{sender}** claimed thicket username `{username}` on {server_url}" 340 + bot_handler.send_message({ 341 + "type": "stream", 342 + "to": self.stream_name, 343 + "subject": self.topic_name, 344 + "content": notification_msg 345 + }) 346 + except Exception as e: 347 + self.logger.error(f"Failed to send username claim notification: {e}") 348 + 349 + self.logger.info(f"User {sender} ({sender_email}) claimed username {username} on {server_url}") 350 + else: 351 + bot_handler.send_reply( 352 + message, 353 + f"❌ Failed to claim username `{username}`. This shouldn't happen - please contact an administrator." 354 + ) 355 + 356 + except Exception as e: 357 + self.logger.error(f"Error processing claim for {username} by {sender}: {e}") 358 + bot_handler.send_reply(message, f"❌ Error processing claim: {str(e)}") 359 + 239 360 def _handle_config_command( 240 - self, 241 - message: Dict[str, Any], 242 - bot_handler: BotHandler, 243 - args: List[str], 361 + self, 362 + message: dict[str, Any], 363 + bot_handler: BotHandler, 364 + args: list[str], 244 365 sender: str 245 366 ) -> None: 246 367 """Handle configuration commands.""" 247 368 if len(args) < 2: 248 369 bot_handler.send_reply(message, "Usage: `@mention config <setting> <value>`") 249 370 return 250 - 371 + 251 372 setting = args[0].lower() 252 373 value = " ".join(args[1:]) 253 - 374 + 254 375 if setting == "stream": 376 + old_value = self.stream_name 255 377 self.stream_name = value 256 378 self._save_bot_config(bot_handler) 257 379 bot_handler.send_reply(message, f"✅ Stream set to: **{value}** (by {sender})") 258 - 380 + self._send_config_change_notification(bot_handler, sender, "stream", old_value, value) 381 + 259 382 elif setting == "topic": 383 + old_value = self.topic_name 260 384 self.topic_name = value 261 385 self._save_bot_config(bot_handler) 262 386 bot_handler.send_reply(message, f"✅ Topic set to: **{value}** (by {sender})") 263 - 387 + self._send_config_change_notification(bot_handler, sender, "topic", old_value, value) 388 + 264 389 elif setting == "interval": 265 390 try: 266 391 interval = int(value) 267 392 if interval < 60: 268 393 bot_handler.send_reply(message, "❌ Interval must be at least 60 seconds") 269 394 return 395 + old_value = self.sync_interval 270 396 self.sync_interval = interval 271 397 self._save_bot_config(bot_handler) 272 398 bot_handler.send_reply(message, f"✅ Sync interval set to: **{interval}s** (by {sender})") 399 + self._send_config_change_notification(bot_handler, sender, "sync interval", f"{old_value}s", f"{interval}s") 273 400 except ValueError: 274 401 bot_handler.send_reply(message, "❌ Invalid interval value. Must be a number of seconds.") 275 - 402 + 403 + elif setting == "max_entries": 404 + try: 405 + max_entries = int(value) 406 + if max_entries < 1 or max_entries > 50: 407 + bot_handler.send_reply(message, "❌ Max entries must be between 1 and 50") 408 + return 409 + old_value = self.max_entries_per_sync 410 + self.max_entries_per_sync = max_entries 411 + self._save_bot_config(bot_handler) 412 + bot_handler.send_reply(message, f"✅ Max entries per sync set to: **{max_entries}** (by {sender})") 413 + self._send_config_change_notification(bot_handler, sender, "max entries per sync", str(old_value), str(max_entries)) 414 + except ValueError: 415 + bot_handler.send_reply(message, "❌ Invalid max entries value. Must be a number.") 416 + 276 417 else: 277 418 bot_handler.send_reply( 278 - message, 279 - f"❌ Unknown setting: {setting}. Available: stream, topic, interval" 419 + message, 420 + f"❌ Unknown setting: {setting}. Available: stream, topic, interval, max_entries" 280 421 ) 281 422 282 423 def _load_bot_config(self, bot_handler: BotHandler) -> None: ··· 286 427 if config_data: 287 428 config = json.loads(config_data) 288 429 self.stream_name = config.get("stream_name") 289 - self.topic_name = config.get("topic_name") 430 + self.topic_name = config.get("topic_name") 290 431 self.sync_interval = config.get("sync_interval", 300) 291 432 self.max_entries_per_sync = config.get("max_entries_per_sync", 10) 292 - except Exception as e: 433 + self.last_sync_time = config.get("last_sync_time") 434 + except Exception: 293 435 # Bot config not found on first run is expected 294 436 pass 295 437 ··· 301 443 "topic_name": self.topic_name, 302 444 "sync_interval": self.sync_interval, 303 445 "max_entries_per_sync": self.max_entries_per_sync, 446 + "last_sync_time": self.last_sync_time, 304 447 } 305 448 bot_handler.storage.put("bot_config", json.dumps(config_data)) 306 449 except Exception as e: 307 450 self.logger.error(f"Error saving bot config: {e}") 308 451 452 + def _load_botrc_defaults(self) -> None: 453 + """Load default configuration from botrc file.""" 454 + try: 455 + import configparser 456 + from pathlib import Path 457 + 458 + botrc_path = Path("bot-config/botrc") 459 + if not botrc_path.exists(): 460 + self.logger.info("No botrc file found, using hardcoded defaults") 461 + return 462 + 463 + config = configparser.ConfigParser() 464 + config.read(botrc_path) 465 + 466 + if "bot" in config: 467 + bot_section = config["bot"] 468 + self.sync_interval = bot_section.getint("sync_interval", 300) 469 + self.max_entries_per_sync = bot_section.getint("max_entries_per_sync", 10) 470 + self.rate_limit_delay = bot_section.getint("rate_limit_delay", 5) 471 + self.posts_per_batch = bot_section.getint("posts_per_batch", 5) 472 + 473 + # Set defaults only if not already configured 474 + default_stream = bot_section.get("default_stream", "").strip() 475 + default_topic = bot_section.get("default_topic", "").strip() 476 + if default_stream: 477 + self.stream_name = default_stream 478 + if default_topic: 479 + self.topic_name = default_topic 480 + 481 + if "catchup" in config: 482 + catchup_section = config["catchup"] 483 + self.catchup_entries = catchup_section.getint("catchup_entries", 5) 484 + 485 + if "notifications" in config: 486 + notifications_section = config["notifications"] 487 + self.config_change_notifications = notifications_section.getboolean("config_change_notifications", True) 488 + self.username_claim_notifications = notifications_section.getboolean("username_claim_notifications", True) 489 + 490 + self.logger.info(f"Loaded configuration from {botrc_path}") 491 + 492 + except Exception as e: 493 + self.logger.error(f"Error loading botrc defaults: {e}") 494 + self.logger.info("Using hardcoded defaults") 495 + 309 496 def _initialize_thicket(self) -> None: 310 497 """Initialize thicket components.""" 311 498 if not self.config_path or not self.config_path.exists(): 312 499 raise ValueError("Thicket config file not found") 313 - 500 + 314 501 # Load thicket configuration 315 502 import yaml 316 503 with open(self.config_path) as f: 317 504 config_data = yaml.safe_load(f) 318 505 self.config = ThicketConfig(**config_data) 319 - 506 + 320 507 # Initialize git store 321 508 self.git_store = GitStore(self.config.git_store) 322 - 509 + 323 510 self.logger.info("Thicket components initialized successfully") 324 - 511 + 325 512 def _validate_debug_mode(self, bot_handler: BotHandler) -> None: 326 513 """Validate debug mode configuration.""" 327 514 if not self.debug_user or not self.git_store: 328 515 return 329 - 516 + 330 517 # Get current Zulip server from environment 331 518 zulip_site_url = os.getenv("THICKET_ZULIP_SITE_URL", "") 332 519 server_url = zulip_site_url.replace("https://", "").replace("http://", "") 333 - 520 + 334 521 # Check if debug user exists in thicket 335 522 user = self.git_store.get_user(self.debug_user) 336 523 if not user: 337 524 raise ValueError(f"Debug user '{self.debug_user}' not found in thicket") 338 - 525 + 339 526 # Check if user has Zulip association for this server 340 527 if not server_url: 341 528 raise ValueError("Could not determine Zulip server URL") 342 - 529 + 343 530 zulip_user_id = user.get_zulip_mention(server_url) 344 531 if not zulip_user_id: 345 532 raise ValueError(f"User '{self.debug_user}' has no Zulip association for server '{server_url}'") 346 - 533 + 347 534 # Try to look up the actual Zulip user ID from the email address 348 535 # But don't fail if we can't - we'll try again when sending messages 349 536 actual_user_id = self._lookup_zulip_user_id(bot_handler, zulip_user_id) ··· 361 548 # If it's already a numeric user ID, return it 362 549 if email_or_id.isdigit(): 363 550 return email_or_id 364 - 551 + 365 552 try: 366 553 client = bot_handler._client 367 554 if not client: 368 555 self.logger.error("No Zulip client available for user lookup") 369 556 return None 370 - 557 + 371 558 # First try the get_user_by_email API if available 372 559 try: 373 560 user_result = client.get_user_by_email(email_or_id) ··· 379 566 return str(user_id) 380 567 except (AttributeError, Exception): 381 568 pass 382 - 569 + 383 570 # Fallback: Get all users and search through them 384 571 users_result = client.get_users() 385 572 if users_result.get('result') == 'success': 386 573 for user in users_result['members']: 387 574 user_email = user.get('email', '') 388 575 delivery_email = user.get('delivery_email', '') 389 - 390 - if (user_email == email_or_id or 576 + 577 + if (user_email == email_or_id or 391 578 delivery_email == email_or_id or 392 579 str(user.get('user_id')) == email_or_id): 393 580 user_id = user.get('user_id') 394 581 return str(user_id) 395 - 582 + 396 583 self.logger.error(f"No user found with identifier '{email_or_id}'. Searched {len(users_result['members'])} users.") 397 584 return None 398 585 else: 399 586 self.logger.error(f"Failed to get users: {users_result.get('msg', 'Unknown error')}") 400 587 return None 401 - 588 + 402 589 except Exception as e: 403 590 self.logger.error(f"Error looking up user ID for '{email_or_id}': {e}") 404 591 return None 405 592 406 - def _lookup_zulip_user_info(self, bot_handler: BotHandler, email_or_id: str) -> Tuple[Optional[str], Optional[str]]: 593 + def _lookup_zulip_user_info(self, bot_handler: BotHandler, email_or_id: str) -> tuple[Optional[str], Optional[str]]: 407 594 """Look up both Zulip user ID and full name from email address.""" 408 595 if email_or_id.isdigit(): 409 596 return email_or_id, None 410 - 597 + 411 598 try: 412 599 client = bot_handler._client 413 600 if not client: 414 601 return None, None 415 - 602 + 416 603 # Try get_user_by_email API first 417 604 try: 418 605 user_result = client.get_user_by_email(email_or_id) ··· 424 611 return str(user_id), full_name 425 612 except AttributeError: 426 613 pass 427 - 614 + 428 615 # Fallback: search all users 429 616 users_result = client.get_users() 430 617 if users_result.get('result') == 'success': 431 618 for user in users_result['members']: 432 - if (user.get('email') == email_or_id or 619 + if (user.get('email') == email_or_id or 433 620 user.get('delivery_email') == email_or_id): 434 621 return str(user.get('user_id')), user.get('full_name', '') 435 - 622 + 436 623 return None, None 437 - 624 + 438 625 except Exception as e: 439 626 self.logger.error(f"Error looking up user info for '{email_or_id}': {e}") 440 627 return None, None ··· 456 643 except Exception as e: 457 644 self.logger.error(f"Error saving posted entries: {e}") 458 645 459 - def _check_initialization(self, message: Dict[str, Any], bot_handler: BotHandler) -> bool: 646 + def _check_initialization(self, message: dict[str, Any], bot_handler: BotHandler) -> bool: 460 647 """Check if thicket is properly initialized.""" 461 648 if not self.git_store or not self.config: 462 649 bot_handler.send_reply( 463 - message, 650 + message, 464 651 "❌ Thicket not initialized. Please check configuration." 465 652 ) 466 653 return False 467 - 654 + 468 655 # In debug mode, we don't need stream/topic configuration 469 656 if self.debug_user: 470 657 return True 471 - 658 + 472 659 if not self.stream_name or not self.topic_name: 473 660 bot_handler.send_reply( 474 661 message, 475 662 "❌ Stream and topic must be configured first. Use `@mention config stream <name>` and `@mention config topic <name>`" 476 663 ) 477 664 return False 478 - 665 + 479 666 return True 480 667 481 668 def _schedule_sync(self, bot_handler: BotHandler) -> None: ··· 484 671 while True: 485 672 try: 486 673 # Check if we can sync 487 - can_sync = (self.git_store and 488 - ((self.stream_name and self.topic_name) or 674 + can_sync = (self.git_store and 675 + ((self.stream_name and self.topic_name) or 489 676 self.debug_user)) 490 - 677 + 491 678 if can_sync: 492 679 self._perform_sync(bot_handler) 493 - 680 + 494 681 time.sleep(self.sync_interval) 495 682 except Exception as e: 496 683 self.logger.error(f"Error in sync loop: {e}") 497 684 time.sleep(60) # Wait before retrying 498 - 685 + 499 686 # Start background thread 500 687 import threading 501 688 sync_thread = threading.Thread(target=sync_loop, daemon=True) 502 689 sync_thread.start() 503 690 504 - def _perform_sync(self, bot_handler: BotHandler) -> List[AtomEntry]: 691 + def _perform_sync(self, bot_handler: BotHandler) -> list[AtomEntry]: 505 692 """Perform thicket sync and return new entries.""" 506 693 if not self.config or not self.git_store: 507 694 return [] 508 - 509 - new_entries: List[Tuple[AtomEntry, str]] = [] # (entry, username) pairs 695 + 696 + new_entries: list[tuple[AtomEntry, str]] = [] # (entry, username) pairs 510 697 is_first_run = len(self.posted_entries) == 0 511 - 698 + 512 699 # Get all users and their feeds from git store 513 700 users_with_feeds = self.git_store.list_all_users_with_feeds() 514 - 701 + 515 702 # Sync each user's feeds 516 703 for username, feed_urls in users_with_feeds: 517 704 for feed_url in feed_urls: ··· 523 710 new_count, _ = loop.run_until_complete( 524 711 sync_feed(self.git_store, username, str(feed_url), dry_run=False) 525 712 ) 526 - 713 + 527 714 entries_to_check = [] 528 - 715 + 529 716 if new_count > 0: 530 717 # Get the newly added entries 531 718 entries_to_check = self.git_store.list_entries(username, limit=new_count) 532 - 719 + 533 720 # Always check for catchup mode on first run 534 721 if is_first_run: 535 - # Catchup mode: get last 5 entries on first run 536 - catchup_entries = self.git_store.list_entries(username, limit=5) 722 + # Catchup mode: get configured number of entries on first run 723 + catchup_entries = self.git_store.list_entries(username, limit=self.catchup_entries) 537 724 entries_to_check = catchup_entries if not entries_to_check else entries_to_check 538 - 725 + 539 726 for entry in entries_to_check: 540 727 entry_key = f"{username}:{entry.id}" 541 728 if entry_key not in self.posted_entries: 542 729 new_entries.append((entry, username)) 543 730 if len(new_entries) >= self.max_entries_per_sync: 544 731 break 545 - 732 + 546 733 finally: 547 734 loop.close() 548 - 735 + 549 736 except Exception as e: 550 737 self.logger.error(f"Error syncing feed {feed_url} for user {username}: {e}") 551 - 738 + 552 739 if len(new_entries) >= self.max_entries_per_sync: 553 740 break 554 - 741 + 555 742 # Post new entries to Zulip with rate limiting 556 743 if new_entries: 557 744 posted_count = 0 558 - 745 + 559 746 for i, (entry, username) in enumerate(new_entries): 560 747 self._post_entry_to_zulip(entry, bot_handler, username) 561 748 self.posted_entries.add(f"{username}:{entry.id}") 562 749 posted_count += 1 563 - 564 - # Rate limiting: pause after every 5 messages 565 - if posted_count % 5 == 0 and i < len(new_entries) - 1: 566 - time.sleep(5) 567 - 750 + 751 + # Rate limiting: pause after configured number of messages 752 + if posted_count % self.posts_per_batch == 0 and i < len(new_entries) - 1: 753 + time.sleep(self.rate_limit_delay) 754 + 568 755 self._save_posted_entries(bot_handler) 569 - 756 + 757 + # Update last sync time 758 + self.last_sync_time = time.time() 759 + 570 760 return [entry for entry, _ in new_entries] 571 761 572 762 def _post_entry_to_zulip(self, entry: AtomEntry, bot_handler: BotHandler, username: str) -> None: ··· 575 765 # Get current Zulip server from environment 576 766 zulip_site_url = os.getenv("THICKET_ZULIP_SITE_URL", "") 577 767 server_url = zulip_site_url.replace("https://", "").replace("http://", "") 578 - 768 + 579 769 # Build author/date info consistently 580 770 mention_info = "" 581 771 if server_url and self.git_store: ··· 586 776 # Look up the actual Zulip full name for proper @mention 587 777 _, zulip_full_name = self._lookup_zulip_user_info(bot_handler, zulip_user_id) 588 778 display_name = zulip_full_name or user.display_name or username 589 - 779 + 590 780 # Check if author is different from the user - avoid redundancy 591 781 author_name = entry.author and entry.author.get("name") 592 782 if author_name and author_name.lower() != display_name.lower(): 593 783 author_info = f" (by {author_name})" 594 784 else: 595 785 author_info = "" 596 - 786 + 597 787 published_info = "" 598 788 if entry.published: 599 789 published_info = f" • {entry.published.strftime('%Y-%m-%d')}" 600 - 790 + 601 791 mention_info = f"@**{display_name}** posted{author_info}{published_info}:\n\n" 602 - 792 + 603 793 # If no Zulip user found, use consistent format without @mention 604 794 if not mention_info: 605 795 user = self.git_store.get_user(username) if self.git_store else None 606 796 display_name = user.display_name if user else username 607 - 797 + 608 798 author_name = entry.author and entry.author.get("name") 609 799 if author_name and author_name.lower() != display_name.lower(): 610 800 author_info = f" (by {author_name})" 611 801 else: 612 802 author_info = "" 613 - 803 + 614 804 published_info = "" 615 805 if entry.published: 616 806 published_info = f" • {entry.published.strftime('%Y-%m-%d')}" 617 - 807 + 618 808 mention_info = f"**{display_name}** posted{author_info}{published_info}:\n\n" 619 - 809 + 620 810 # Format the message with HTML processing 621 811 message_lines = [ 622 812 f"**{entry.title}**", 623 813 f"🔗 {entry.link}", 624 814 ] 625 - 815 + 626 816 if entry.summary: 627 817 # Process HTML in summary and truncate if needed 628 818 processed_summary = self._process_html_content(entry.summary) 629 819 if len(processed_summary) > 400: 630 820 processed_summary = processed_summary[:397] + "..." 631 821 message_lines.append(f"\n{processed_summary}") 632 - 822 + 633 823 message_content = mention_info + "\n".join(message_lines) 634 - 824 + 635 825 # Choose destination based on mode 636 826 if self.debug_user and self.debug_zulip_user_id: 637 827 # Debug mode: send DM 638 828 debug_message = f"🐛 **DEBUG:** New article from thicket user `{username}`:\n\n{message_content}" 639 - 829 + 640 830 # Ensure we have the numeric user ID 641 831 user_id_to_use = self.debug_zulip_user_id 642 832 if not user_id_to_use.isdigit(): ··· 648 838 else: 649 839 self.logger.error(f"Could not resolve user ID for {self.debug_zulip_user_id}") 650 840 return 651 - 841 + 652 842 try: 653 843 # For private messages, user_id needs to be an integer, not string 654 844 user_id_int = int(user_id_to_use) ··· 661 851 # If conversion to int fails, user_id_to_use might be an email 662 852 try: 663 853 bot_handler.send_message({ 664 - "type": "private", 854 + "type": "private", 665 855 "to": [user_id_to_use], # Try as string (email) 666 856 "content": debug_message 667 857 }) ··· 681 871 "content": message_content 682 872 }) 683 873 self.logger.info(f"Posted entry to stream: {entry.title} (user: {username})") 684 - 874 + 685 875 except Exception as e: 686 876 self.logger.error(f"Error posting entry to Zulip: {e}") 687 877 ··· 689 879 """Process HTML content from feeds to clean Zulip-compatible markdown.""" 690 880 if not html_content: 691 881 return "" 692 - 882 + 693 883 try: 694 884 # Try to use markdownify for proper HTML to Markdown conversion 695 885 from markdownify import markdownify as md 696 - 886 + 697 887 # Convert HTML to Markdown with compact settings for summaries 698 888 markdown = md( 699 889 html_content, ··· 701 891 bullets="-", # Use - for bullets 702 892 convert=['a', 'b', 'strong', 'i', 'em', 'code', 'pre', 'p', 'br', 'ul', 'ol', 'li', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6'] 703 893 ).strip() 704 - 894 + 705 895 # Post-process to convert headings to bold for compact summaries 706 896 import re 707 897 # Convert markdown headers to bold with period 708 898 markdown = re.sub(r'^#{1,6}\s*(.+)$', r'**\1.**', markdown, flags=re.MULTILINE) 709 - 899 + 710 900 # Clean up excessive newlines and make more compact 711 901 markdown = re.sub(r'\n\s*\n\s*\n+', ' ', markdown) # Multiple newlines become space 712 902 markdown = re.sub(r'\n\s*\n', '. ', markdown) # Double newlines become sentence breaks 713 903 markdown = re.sub(r'\n', ' ', markdown) # Single newlines become spaces 714 - 904 + 715 905 # Clean up double periods and excessive whitespace 716 906 markdown = re.sub(r'\.\.+', '.', markdown) 717 907 markdown = re.sub(r'\s+', ' ', markdown) 718 908 return markdown.strip() 719 - 909 + 720 910 except ImportError: 721 911 # Fallback: manual HTML processing 722 912 import re 723 913 content = html_content 724 - 914 + 725 915 # Convert headings to bold with periods for compact summaries 726 916 content = re.sub(r'<h[1-6](?:\s[^>]*)?>([^<]*)</h[1-6]>', r'**\1.** ', content, flags=re.IGNORECASE) 727 - 917 + 728 918 # Convert common HTML elements to Markdown 729 919 content = re.sub(r'<(?:strong|b)(?:\s[^>]*)?>([^<]*)</(?:strong|b)>', r'**\1**', content, flags=re.IGNORECASE) 730 920 content = re.sub(r'<(?:em|i)(?:\s[^>]*)?>([^<]*)</(?:em|i)>', r'*\1*', content, flags=re.IGNORECASE) 731 921 content = re.sub(r'<code(?:\s[^>]*)?>([^<]*)</code>', r'`\1`', content, flags=re.IGNORECASE) 732 922 content = re.sub(r'<a(?:\s[^>]*?)?\s*href=["\']([^"\']*)["\'](?:\s[^>]*)?>([^<]*)</a>', r'[\2](\1)', content, flags=re.IGNORECASE) 733 - 923 + 734 924 # Convert block elements to spaces instead of newlines for compactness 735 925 content = re.sub(r'<br\s*/?>', ' ', content, flags=re.IGNORECASE) 736 926 content = re.sub(r'</p>\s*<p>', '. ', content, flags=re.IGNORECASE) 737 927 content = re.sub(r'</?(?:p|div)(?:\s[^>]*)?>', ' ', content, flags=re.IGNORECASE) 738 - 928 + 739 929 # Remove remaining HTML tags 740 930 content = re.sub(r'<[^>]+>', '', content) 741 - 931 + 742 932 # Clean up whitespace and make compact 743 933 content = re.sub(r'\s+', ' ', content) # Multiple whitespace becomes single space 744 934 content = re.sub(r'\.\.+', '.', content) # Multiple periods become single period 745 935 return content.strip() 746 - 936 + 747 937 except Exception as e: 748 938 self.logger.error(f"Error processing HTML content: {e}") 749 939 # Last resort: just strip HTML tags 750 940 import re 751 941 return re.sub(r'<[^>]+>', '', html_content).strip() 752 942 943 + def _get_schedule_info(self) -> str: 944 + """Get schedule information string.""" 945 + lines = [] 753 946 754 - handler_class = ThicketBotHandler 947 + if self.last_sync_time: 948 + import datetime 949 + last_sync = datetime.datetime.fromtimestamp(self.last_sync_time) 950 + next_sync = last_sync + datetime.timedelta(seconds=self.sync_interval) 951 + now = datetime.datetime.now() 952 + 953 + # Calculate time until next sync 954 + time_until_next = next_sync - now 955 + 956 + if time_until_next.total_seconds() > 0: 957 + minutes, seconds = divmod(int(time_until_next.total_seconds()), 60) 958 + hours, minutes = divmod(minutes, 60) 959 + 960 + if hours > 0: 961 + time_str = f"{hours}h {minutes}m {seconds}s" 962 + elif minutes > 0: 963 + time_str = f"{minutes}m {seconds}s" 964 + else: 965 + time_str = f"{seconds}s" 966 + 967 + lines.extend([ 968 + f"🕐 **Last Sync:** {last_sync.strftime('%H:%M:%S')}", 969 + f"⏰ **Next Sync:** {next_sync.strftime('%H:%M:%S')} (in {time_str})", 970 + ]) 971 + else: 972 + lines.extend([ 973 + f"🕐 **Last Sync:** {last_sync.strftime('%H:%M:%S')}", 974 + f"⏰ **Next Sync:** Due now (running every {self.sync_interval}s)", 975 + ]) 976 + else: 977 + lines.append("🕐 **Last Sync:** Never (bot starting up)") 978 + 979 + # Add sync frequency info 980 + if self.sync_interval >= 3600: 981 + frequency_str = f"{self.sync_interval // 3600}h {(self.sync_interval % 3600) // 60}m" 982 + elif self.sync_interval >= 60: 983 + frequency_str = f"{self.sync_interval // 60}m {self.sync_interval % 60}s" 984 + else: 985 + frequency_str = f"{self.sync_interval}s" 986 + 987 + lines.append(f"🔄 **Sync Frequency:** Every {frequency_str}") 988 + 989 + return "\n".join(lines) 990 + 991 + def _send_config_change_notification(self, bot_handler: BotHandler, changer: str, setting: str, old_value: Optional[str], new_value: str) -> None: 992 + """Send configuration change notification if enabled.""" 993 + if not self.config_change_notifications or self.debug_user: 994 + return 995 + 996 + # Don't send notification if stream/topic aren't configured yet 997 + if not self.stream_name or not self.topic_name: 998 + return 999 + 1000 + try: 1001 + old_display = old_value if old_value else "(not set)" 1002 + notification_msg = f"⚙️ **{changer}** changed {setting}: `{old_display}` → `{new_value}`" 1003 + 1004 + bot_handler.send_message({ 1005 + "type": "stream", 1006 + "to": self.stream_name, 1007 + "subject": self.topic_name, 1008 + "content": notification_msg 1009 + }) 1010 + except Exception as e: 1011 + self.logger.error(f"Failed to send config change notification: {e}") 1012 + 1013 + 1014 + handler_class = ThicketBotHandler 1015 +
+12 -1
src/thicket/cli/commands/__init__.py
··· 1 1 """CLI commands for thicket.""" 2 2 3 3 # Import all commands to register them with the main app 4 - from . import add, bot, duplicates, info_cmd, init, list_cmd, search, sync, upload, zulip 4 + from . import ( 5 + add, 6 + bot, 7 + duplicates, 8 + info_cmd, 9 + init, 10 + list_cmd, 11 + search, 12 + sync, 13 + upload, 14 + zulip, 15 + ) 5 16 6 17 __all__ = ["add", "bot", "duplicates", "info_cmd", "init", "list_cmd", "search", "sync", "upload", "zulip"]
+35 -33
src/thicket/cli/commands/bot.py
··· 30 30 daemon: bool = typer.Option( 31 31 False, 32 32 "--daemon", 33 - "-d", 33 + "-d", 34 34 help="Run bot in daemon mode (background)", 35 35 ), 36 36 debug_user: str = typer.Option( ··· 46 46 - test: Test bot functionality 47 47 - status: Show bot status 48 48 """ 49 - 49 + 50 50 if action == "run": 51 51 _run_bot(config_file, thicket_config, daemon, debug_user) 52 52 elif action == "test": ··· 64 64 if not config_file.exists(): 65 65 print_error(f"Configuration file not found: {config_file}") 66 66 print_info(f"Copy bot-config/zuliprc.template to {config_file} and configure it") 67 + print_info("See bot-config/README.md for setup instructions") 67 68 raise typer.Exit(1) 68 - 69 + 69 70 if not thicket_config.exists(): 70 71 print_error(f"Thicket configuration file not found: {thicket_config}") 71 72 print_info("Run `thicket init` to create a thicket.yaml file") 72 73 raise typer.Exit(1) 73 - 74 + 74 75 # Parse zuliprc to extract server URL 75 76 zulip_site_url = _parse_zulip_config(config_file) 76 - 77 + 77 78 print_info(f"Starting Thicket Zulip bot with config: {config_file}") 78 79 print_info(f"Using thicket config: {thicket_config}") 79 - 80 + 80 81 if debug_user: 81 82 print_info(f"🐛 DEBUG MODE: Will send DMs to thicket user '{debug_user}' instead of posting to streams") 82 - 83 + 83 84 if daemon: 84 85 print_info("Running in daemon mode...") 85 86 else: 86 87 print_info("Bot will be available as @thicket in your Zulip chat") 87 88 print_info("Press Ctrl+C to stop the bot") 88 - 89 + 89 90 try: 90 91 # Build the command 91 92 cmd = [ ··· 93 94 "src/thicket/bots/thicket_bot.py", 94 95 "--config-file", str(config_file) 95 96 ] 96 - 97 + 97 98 # Add environment variables for bot configuration 98 99 import os 99 100 env = os.environ.copy() 100 - 101 + 101 102 # Always pass thicket config path 102 103 env["THICKET_CONFIG_PATH"] = str(thicket_config.absolute()) 103 - 104 + 104 105 # Add debug user if specified 105 106 if debug_user: 106 107 env["THICKET_DEBUG_USER"] = debug_user 107 - 108 + 108 109 # Pass Zulip server URL to bot 109 110 if zulip_site_url: 110 111 env["THICKET_ZULIP_SITE_URL"] = zulip_site_url 111 - 112 + 112 113 if daemon: 113 114 # Run in background 114 115 process = subprocess.Popen( ··· 122 123 else: 123 124 # Run in foreground 124 125 subprocess.run(cmd, check=True, env=env) 125 - 126 + 126 127 except subprocess.CalledProcessError as e: 127 128 print_error(f"Failed to start bot: {e}") 128 129 raise typer.Exit(1) ··· 134 135 """Parse zuliprc file to extract the site URL.""" 135 136 try: 136 137 import configparser 137 - 138 + 138 139 config = configparser.ConfigParser() 139 140 config.read(config_file) 140 - 141 + 141 142 if "api" in config and "site" in config["api"]: 142 143 site_url = config["api"]["site"] 143 144 print_info(f"Detected Zulip server: {site_url}") ··· 145 146 else: 146 147 print_error("Could not find 'site' in zuliprc [api] section") 147 148 return "" 148 - 149 + 149 150 except Exception as e: 150 151 print_error(f"Error parsing zuliprc: {e}") 151 152 return "" ··· 154 155 def _test_bot() -> None: 155 156 """Test bot functionality.""" 156 157 print_info("Testing Thicket Zulip bot...") 157 - 158 + 158 159 try: 159 160 from ...bots.test_bot import BotTester 160 - 161 + 161 162 # Create bot tester 162 163 tester = BotTester() 163 - 164 + 164 165 # Test basic functionality 165 166 console.print("✓ Testing help command...", style="green") 166 167 responses = tester.send_command("help") 167 168 assert len(responses) == 1 168 169 assert "Thicket Feed Bot" in tester.get_last_response_content() 169 - 170 - console.print("✓ Testing status command...", style="green") 170 + 171 + console.print("✓ Testing status command...", style="green") 171 172 responses = tester.send_command("status") 172 173 assert len(responses) == 1 173 174 assert "Status" in tester.get_last_response_content() 174 - 175 + 175 176 console.print("✓ Testing config commands...", style="green") 176 177 responses = tester.send_command("config stream test-stream") 177 178 tester.assert_response_contains("Stream set to") 178 - 179 + 179 180 responses = tester.send_command("config topic test-topic") 180 181 tester.assert_response_contains("Topic set to") 181 - 182 + 182 183 responses = tester.send_command("config interval 300") 183 184 tester.assert_response_contains("Sync interval set to") 184 - 185 + 185 186 print_success("All bot tests passed!") 186 - 187 + 187 188 except Exception as e: 188 189 print_error(f"Bot test failed: {e}") 189 190 raise typer.Exit(1) ··· 193 194 """Show bot status.""" 194 195 console.print("Thicket Zulip Bot Status", style="bold blue") 195 196 console.print() 196 - 197 + 197 198 # Check config file 198 199 if config_file.exists(): 199 200 console.print(f"✓ Config file: {config_file}", style="green") 200 201 else: 201 202 console.print(f"✗ Config file not found: {config_file}", style="red") 202 203 console.print(" Copy bot-config/zuliprc.template and configure it", style="yellow") 203 - 204 + console.print(" See bot-config/README.md for setup instructions", style="yellow") 205 + 204 206 # Check dependencies 205 207 try: 206 208 import zulip_bots ··· 208 210 console.print(f"✓ zulip-bots version: {version}", style="green") 209 211 except ImportError: 210 212 console.print("✗ zulip-bots not installed", style="red") 211 - 213 + 212 214 try: 213 215 from ...bots.thicket_bot import ThicketBotHandler 214 216 console.print("✓ ThicketBotHandler available", style="green") 215 217 except ImportError as e: 216 218 console.print(f"✗ Bot handler not available: {e}", style="red") 217 - 219 + 218 220 # Check bot file 219 221 bot_file = Path("src/thicket/bots/thicket_bot.py") 220 222 if bot_file.exists(): 221 223 console.print(f"✓ Bot file: {bot_file}", style="green") 222 224 else: 223 225 console.print(f"✗ Bot file not found: {bot_file}", style="red") 224 - 226 + 225 227 console.print() 226 228 console.print("To run the bot:", style="bold") 227 229 console.print(f" thicket bot run --config {config_file}") 228 230 console.print() 229 - console.print("For help setting up the bot, see: docs/ZULIP_BOT.md", style="dim") 231 + console.print("For help setting up the bot, see: docs/ZULIP_BOT.md", style="dim")
+18 -20
src/thicket/cli/commands/search.py
··· 7 7 import typer 8 8 from rich.console import Console 9 9 from rich.table import Table 10 - from rich.text import Text 11 10 12 11 from ...core.typesense_client import TypesenseClient, TypesenseConfig 13 12 from ..main import app 14 - from ..utils import load_config 15 13 16 14 console = Console() 17 15 logger = logging.getLogger(__name__) ··· 139 137 ) 140 138 typesense_config.connection_timeout = timeout 141 139 142 - console.print(f"[bold blue]Searching thicket entries[/bold blue]") 140 + console.print("[bold blue]Searching thicket entries[/bold blue]") 143 141 console.print(f"Query: [cyan]{query}[/cyan]") 144 142 if user: 145 143 console.print(f"User filter: [yellow]{user}[/yellow]") ··· 159 157 # Perform search 160 158 try: 161 159 results = typesense_client.search(query, search_params) 162 - 160 + 163 161 if raw: 164 162 import json 165 163 console.print(json.dumps(results, indent=2)) ··· 189 187 return 190 188 191 189 console.print(f"\n[green]Found {found} results in {search_time}ms[/green]") 192 - 190 + 193 191 table = Table(title=f"Search Results for '{query}'", show_lines=True) 194 192 table.add_column("Score", style="green", width=8, no_wrap=True) 195 193 table.add_column("User", style="cyan", width=15, no_wrap=True) ··· 199 197 200 198 for hit in hits: 201 199 doc = hit['document'] 202 - 200 + 203 201 # Format score 204 202 score = f"{hit.get('text_match', 0):.2f}" 205 - 203 + 206 204 # Format user 207 205 user_display = doc.get('user_display_name', doc.get('username', 'Unknown')) 208 206 if len(user_display) > 12: 209 207 user_display = user_display[:9] + "..." 210 - 208 + 211 209 # Format title 212 210 title = doc.get('title', 'Untitled') 213 211 if len(title) > 40: 214 212 title = title[:37] + "..." 215 - 213 + 216 214 # Format date 217 215 updated_timestamp = doc.get('updated', 0) 218 216 if updated_timestamp: ··· 221 219 updated_str = updated_date.strftime("%Y-%m-%d") 222 220 else: 223 221 updated_str = "Unknown" 224 - 222 + 225 223 # Format summary 226 224 summary = doc.get('summary') or doc.get('content', '') 227 225 if summary: ··· 243 241 ) 244 242 245 243 console.print(table) 246 - 244 + 247 245 # Show additional info 248 246 console.print(f"\n[dim]Showing {len(hits)} of {found} results[/dim]") 249 247 if len(hits) < found: ··· 254 252 """Display search results in a compact format.""" 255 253 hits = results.get('hits', []) 256 254 found = results.get('found', 0) 257 - 255 + 258 256 if not hits: 259 257 console.print("\n[yellow]No results found.[/yellow]") 260 258 return 261 259 262 260 console.print(f"\n[green]Found {found} results[/green]\n") 263 - 261 + 264 262 for i, hit in enumerate(hits, 1): 265 263 doc = hit['document'] 266 264 score = hit.get('text_match', 0) 267 - 265 + 268 266 # Header with score and user 269 267 user = doc.get('user_display_name', doc.get('username', 'Unknown')) 270 268 console.print(f"[green]{i:2d}.[/green] [cyan]{user}[/cyan] [dim](score: {score:.2f})[/dim]") 271 - 269 + 272 270 # Title 273 271 title = doc.get('title', 'Untitled') 274 272 console.print(f" [bold]{title}[/bold]") 275 - 273 + 276 274 # Date and link 277 275 updated_timestamp = doc.get('updated', 0) 278 276 if updated_timestamp: ··· 281 279 updated_str = updated_date.strftime("%Y-%m-%d %H:%M") 282 280 else: 283 281 updated_str = "Unknown date" 284 - 282 + 285 283 link = doc.get('link', '') 286 284 console.print(f" [blue]{updated_str}[/blue] - [link={link}]{link}[/link]") 287 - 285 + 288 286 # Summary 289 287 summary = doc.get('summary') or doc.get('content', '') 290 288 if summary: ··· 294 292 if len(summary) > 150: 295 293 summary = summary[:147] + "..." 296 294 console.print(f" [dim]{summary}[/dim]") 297 - 298 - console.print() # Empty line between results 295 + 296 + console.print() # Empty line between results
+30 -30
src/thicket/cli/commands/zulip.py
··· 37 37 try: 38 38 config = load_config(config_file) 39 39 git_store = GitStore(config.git_store) 40 - 40 + 41 41 # Check if user exists 42 42 user = git_store.get_user(username) 43 43 if not user: 44 44 print_error(f"User '{username}' not found") 45 45 raise typer.Exit(1) 46 - 46 + 47 47 # Add association 48 48 if git_store.add_zulip_association(username, server, user_id): 49 49 print_success(f"Added Zulip association for {username}: {user_id}@{server}") 50 50 git_store.commit_changes(f"Add Zulip association for {username}") 51 51 else: 52 52 print_info(f"Association already exists for {username}: {user_id}@{server}") 53 - 53 + 54 54 except Exception as e: 55 55 print_error(f"Failed to add Zulip association: {e}") 56 56 raise typer.Exit(1) ··· 76 76 try: 77 77 config = load_config(config_file) 78 78 git_store = GitStore(config.git_store) 79 - 79 + 80 80 # Check if user exists 81 81 user = git_store.get_user(username) 82 82 if not user: 83 83 print_error(f"User '{username}' not found") 84 84 raise typer.Exit(1) 85 - 85 + 86 86 # Remove association 87 87 if git_store.remove_zulip_association(username, server, user_id): 88 88 print_success(f"Removed Zulip association for {username}: {user_id}@{server}") ··· 90 90 else: 91 91 print_error(f"Association not found for {username}: {user_id}@{server}") 92 92 raise typer.Exit(1) 93 - 93 + 94 94 except Exception as e: 95 95 print_error(f"Failed to remove Zulip association: {e}") 96 96 raise typer.Exit(1) ··· 117 117 try: 118 118 config = load_config(config_file) 119 119 git_store = GitStore(config.git_store) 120 - 120 + 121 121 # Create table 122 122 table = Table(title="Zulip Associations") 123 123 table.add_column("Username", style="cyan") 124 124 table.add_column("Server", style="green") 125 125 table.add_column("User ID", style="yellow") 126 - 126 + 127 127 if username: 128 128 # List for specific user 129 129 user = git_store.get_user(username) 130 130 if not user: 131 131 print_error(f"User '{username}' not found") 132 132 raise typer.Exit(1) 133 - 133 + 134 134 if not user.zulip_associations: 135 135 print_info(f"No Zulip associations for {username}") 136 136 return 137 - 137 + 138 138 for assoc in user.zulip_associations: 139 139 table.add_row(username, assoc.server, assoc.user_id) 140 140 else: 141 141 # List for all users 142 142 index = git_store._load_index() 143 143 has_associations = False 144 - 144 + 145 145 for username, user in index.users.items(): 146 146 for assoc in user.zulip_associations: 147 147 table.add_row(username, assoc.server, assoc.user_id) 148 148 has_associations = True 149 - 149 + 150 150 if not has_associations: 151 151 print_info("No Zulip associations found") 152 152 return 153 - 153 + 154 154 console.print(table) 155 - 155 + 156 156 except Exception as e: 157 157 print_error(f"Failed to list Zulip associations: {e}") 158 158 raise typer.Exit(1) ··· 184 184 thicket zulip-import associations.csv 185 185 """ 186 186 import csv 187 - 187 + 188 188 try: 189 189 config = load_config(config_file) 190 190 git_store = GitStore(config.git_store) 191 - 191 + 192 192 if not csv_file.exists(): 193 193 print_error(f"CSV file not found: {csv_file}") 194 194 raise typer.Exit(1) 195 - 195 + 196 196 added = 0 197 197 skipped = 0 198 198 errors = 0 199 - 200 - with open(csv_file, 'r') as f: 199 + 200 + with open(csv_file) as f: 201 201 reader = csv.reader(f) 202 202 for row_num, row in enumerate(reader, 1): 203 203 if len(row) != 3: 204 204 print_error(f"Line {row_num}: Invalid format (expected 3 columns)") 205 205 errors += 1 206 206 continue 207 - 207 + 208 208 username, server, user_id = [col.strip() for col in row] 209 - 209 + 210 210 # Skip empty lines 211 211 if not username: 212 212 continue 213 - 213 + 214 214 # Check if user exists 215 215 user = git_store.get_user(username) 216 216 if not user: 217 217 print_error(f"Line {row_num}: User '{username}' not found") 218 218 errors += 1 219 219 continue 220 - 220 + 221 221 if dry_run: 222 222 # Check if association would be added 223 223 exists = any( 224 - a.server == server and a.user_id == user_id 224 + a.server == server and a.user_id == user_id 225 225 for a in user.zulip_associations 226 226 ) 227 227 if exists: ··· 238 238 else: 239 239 print_info(f"Skipped existing: {username} -> {user_id}@{server}") 240 240 skipped += 1 241 - 241 + 242 242 # Summary 243 243 console.print() 244 244 if dry_run: 245 - console.print(f"[bold]Dry run summary:[/bold]") 245 + console.print("[bold]Dry run summary:[/bold]") 246 246 console.print(f" Would add: {added}") 247 247 else: 248 - console.print(f"[bold]Import summary:[/bold]") 248 + console.print("[bold]Import summary:[/bold]") 249 249 console.print(f" Added: {added}") 250 250 if not dry_run and added > 0: 251 251 git_store.commit_changes(f"Import {added} Zulip associations from CSV") 252 - 252 + 253 253 console.print(f" Skipped: {skipped}") 254 254 console.print(f" Errors: {errors}") 255 - 255 + 256 256 except Exception as e: 257 257 print_error(f"Failed to import Zulip associations: {e}") 258 - raise typer.Exit(1) 258 + raise typer.Exit(1)
+9 -1
src/thicket/cli/main.py
··· 47 47 48 48 49 49 # Import commands to register them 50 - from .commands import add, duplicates, info_cmd, init, list_cmd, sync, upload # noqa: F401 50 + from .commands import ( # noqa: F401 51 + add, 52 + duplicates, 53 + info_cmd, 54 + init, 55 + list_cmd, 56 + sync, 57 + upload, 58 + ) 51 59 52 60 if __name__ == "__main__": 53 61 app()
+10 -10
src/thicket/core/git_store.py
··· 151 151 self._save_index(index) 152 152 153 153 return True 154 - 154 + 155 155 def add_zulip_association(self, username: str, server: str, user_id: str) -> bool: 156 156 """Add a Zulip association to a user.""" 157 157 index = self._load_index() 158 158 user = index.get_user(username) 159 - 159 + 160 160 if not user: 161 161 return False 162 - 162 + 163 163 result = user.add_zulip_association(server, user_id) 164 164 if result: 165 165 index.add_user(user) 166 166 self._save_index(index) 167 - 167 + 168 168 return result 169 - 169 + 170 170 def remove_zulip_association(self, username: str, server: str, user_id: str) -> bool: 171 171 """Remove a Zulip association from a user.""" 172 172 index = self._load_index() 173 173 user = index.get_user(username) 174 - 174 + 175 175 if not user: 176 176 return False 177 - 177 + 178 178 result = user.remove_zulip_association(server, user_id) 179 179 if result: 180 180 index.add_user(user) 181 181 self._save_index(index) 182 - 182 + 183 183 return result 184 - 184 + 185 185 def get_zulip_associations(self, username: str) -> list: 186 186 """Get all Zulip associations for a user.""" 187 187 user = self.get_user(username) ··· 383 383 user = self.get_user(username) 384 384 if not user: 385 385 return [] 386 - 386 + 387 387 # Feed URLs are stored in the user metadata 388 388 return user.feeds 389 389
+5 -5
src/thicket/models/user.py
··· 8 8 9 9 class ZulipAssociation(BaseModel): 10 10 """Association between a user and their Zulip identity.""" 11 - 11 + 12 12 server: str # Zulip server URL (e.g., "yourorg.zulipchat.com") 13 13 user_id: str # Zulip user ID or email for @mentions 14 - 14 + 15 15 def __hash__(self) -> int: 16 16 """Make hashable for use in sets.""" 17 17 return hash((self.server, self.user_id)) ··· 45 45 """Increment the entry count by the given amount.""" 46 46 self.entry_count += count 47 47 self.update_timestamp() 48 - 48 + 49 49 def add_zulip_association(self, server: str, user_id: str) -> bool: 50 50 """Add a Zulip association if it doesn't exist. Returns True if added.""" 51 51 association = ZulipAssociation(server=server, user_id=user_id) ··· 54 54 self.update_timestamp() 55 55 return True 56 56 return False 57 - 57 + 58 58 def remove_zulip_association(self, server: str, user_id: str) -> bool: 59 59 """Remove a Zulip association. Returns True if removed.""" 60 60 association = ZulipAssociation(server=server, user_id=user_id) ··· 63 63 self.update_timestamp() 64 64 return True 65 65 return False 66 - 66 + 67 67 def get_zulip_mention(self, server: str) -> Optional[str]: 68 68 """Get the Zulip user_id for @mentions on a specific server.""" 69 69 for association in self.zulip_associations:
+50 -50
tests/test_bot.py
··· 1 1 """Tests for the Thicket Zulip bot.""" 2 2 3 - import json 4 - import tempfile 5 - from pathlib import Path 6 - from unittest.mock import patch 7 3 8 4 import pytest 9 5 10 - from thicket.bots.test_bot import BotTester, MockBotHandler, create_test_message, create_test_entry 6 + from thicket.bots.test_bot import ( 7 + BotTester, 8 + MockBotHandler, 9 + create_test_entry, 10 + create_test_message, 11 + ) 11 12 from thicket.bots.thicket_bot import ThicketBotHandler 12 13 13 14 ··· 30 31 """Test help command response.""" 31 32 message = create_test_message("@thicket help") 32 33 self.bot.handle_message(message, self.handler) 33 - 34 + 34 35 assert len(self.handler.sent_messages) == 1 35 36 response = self.handler.sent_messages[0]["content"] 36 37 assert "Thicket Feed Bot" in response ··· 39 40 """Test status command when bot is not configured.""" 40 41 message = create_test_message("@thicket status") 41 42 self.bot.handle_message(message, self.handler) 42 - 43 + 43 44 assert len(self.handler.sent_messages) == 1 44 45 response = self.handler.sent_messages[0]["content"] 45 46 assert "Not configured" in response ··· 50 51 """Test setting stream configuration.""" 51 52 message = create_test_message("@thicket config stream general") 52 53 self.bot.handle_message(message, self.handler) 53 - 54 + 54 55 assert len(self.handler.sent_messages) == 1 55 56 response = self.handler.sent_messages[0]["content"] 56 57 assert "Stream set to: **general**" in response ··· 60 61 """Test setting topic configuration.""" 61 62 message = create_test_message("@thicket config topic 'Feed Updates'") 62 63 self.bot.handle_message(message, self.handler) 63 - 64 + 64 65 assert len(self.handler.sent_messages) == 1 65 66 response = self.handler.sent_messages[0]["content"] 66 67 assert "Topic set to:" in response and "Feed Updates" in response ··· 70 71 """Test setting sync interval.""" 71 72 message = create_test_message("@thicket config interval 600") 72 73 self.bot.handle_message(message, self.handler) 73 - 74 + 74 75 assert len(self.handler.sent_messages) == 1 75 76 response = self.handler.sent_messages[0]["content"] 76 77 assert "Sync interval set to: **600s**" in response ··· 80 81 """Test setting sync interval that's too small.""" 81 82 message = create_test_message("@thicket config interval 30") 82 83 self.bot.handle_message(message, self.handler) 83 - 84 + 84 85 assert len(self.handler.sent_messages) == 1 85 86 response = self.handler.sent_messages[0]["content"] 86 87 assert "must be at least 60 seconds" in response ··· 90 91 """Test setting config path that doesn't exist.""" 91 92 message = create_test_message("@thicket config path /nonexistent/config.yaml") 92 93 self.bot.handle_message(message, self.handler) 93 - 94 + 94 95 assert len(self.handler.sent_messages) == 1 95 96 response = self.handler.sent_messages[0]["content"] 96 97 assert "Config file not found" in response ··· 99 100 """Test unknown command handling.""" 100 101 message = create_test_message("@thicket unknown") 101 102 self.bot.handle_message(message, self.handler) 102 - 103 + 103 104 assert len(self.handler.sent_messages) == 1 104 105 response = self.handler.sent_messages[0]["content"] 105 106 assert "Unknown command: unknown" in response ··· 110 111 self.bot.stream_name = "test-stream" 111 112 self.bot.topic_name = "test-topic" 112 113 self.bot.sync_interval = 600 113 - 114 + 114 115 # Save config 115 116 self.bot._save_bot_config(self.handler) 116 - 117 + 117 118 # Create new bot instance 118 119 new_bot = ThicketBotHandler() 119 120 new_bot._load_bot_config(self.handler) 120 - 121 + 121 122 # Check config was loaded 122 123 assert new_bot.stream_name == "test-stream" 123 124 assert new_bot.topic_name == "test-topic" ··· 127 128 """Test that posted entries are persisted.""" 128 129 # Add some entries 129 130 self.bot.posted_entries = {"user1:entry1", "user2:entry2"} 130 - 131 + 131 132 # Save entries 132 133 self.bot._save_posted_entries(self.handler) 133 - 134 + 134 135 # Create new bot instance 135 136 new_bot = ThicketBotHandler() 136 137 new_bot._load_posted_entries(self.handler) 137 - 138 + 138 139 # Check entries were loaded 139 140 assert new_bot.posted_entries == {"user1:entry1", "user2:entry2"} 140 141 ··· 148 149 """Test cleaning mentions from messages.""" 149 150 cleaned = self.bot._clean_mention("@Thicket Bot status", self.handler) 150 151 assert cleaned == "status" 151 - 152 + 152 153 cleaned = self.bot._clean_mention("@thicket help", self.handler) 153 154 assert cleaned == "help" 154 155 ··· 156 157 """Test sync now command when not initialized.""" 157 158 message = create_test_message("@thicket sync now") 158 159 self.bot.handle_message(message, self.handler) 159 - 160 + 160 161 assert len(self.handler.sent_messages) == 1 161 162 response = self.handler.sent_messages[0]["content"] 162 163 assert "not initialized" in response.lower() 163 - 164 + 164 165 def test_debug_mode_initialization(self) -> None: 165 166 """Test debug mode initialization.""" 166 167 import os 167 - 168 + 168 169 # Mock environment variable 169 170 os.environ["THICKET_DEBUG_USER"] = "testuser" 170 - 171 + 171 172 try: 172 173 bot = ThicketBotHandler() 173 174 # Simulate initialize call 174 175 bot.debug_user = os.getenv("THICKET_DEBUG_USER") 175 - 176 + 176 177 assert bot.debug_user == "testuser" 177 178 assert bot.debug_zulip_user_id is None # Not validated yet 178 179 finally: 179 180 # Clean up 180 181 if "THICKET_DEBUG_USER" in os.environ: 181 182 del os.environ["THICKET_DEBUG_USER"] 182 - 183 + 183 184 def test_debug_mode_status(self) -> None: 184 185 """Test status command in debug mode.""" 185 186 self.bot.debug_user = "testuser" 186 187 self.bot.debug_zulip_user_id = "test.user" 187 - 188 + 188 189 message = create_test_message("@thicket status") 189 190 self.bot.handle_message(message, self.handler) 190 - 191 + 191 192 assert len(self.handler.sent_messages) == 1 192 193 response = self.handler.sent_messages[0]["content"] 193 194 assert "**Debug Mode:** ENABLED" in response 194 195 assert "**Debug User:** testuser" in response 195 196 assert "**Debug Zulip ID:** test.user" in response 196 - 197 + 197 198 def test_debug_mode_check_initialization(self) -> None: 198 199 """Test initialization check in debug mode.""" 199 200 from unittest.mock import Mock 200 - 201 + 201 202 # Setup mock git store and config 202 203 self.bot.git_store = Mock() 203 204 self.bot.config = Mock() 204 205 self.bot.debug_user = "testuser" 205 206 self.bot.debug_zulip_user_id = "test.user" 206 - 207 + 207 208 message = create_test_message("@thicket sync now") 208 - 209 + 209 210 # Should pass with debug mode properly set up 210 211 result = self.bot._check_initialization(message, self.handler) 211 212 assert result is True 212 - 213 + 213 214 # Should fail if debug_zulip_user_id is missing 214 215 self.bot.debug_zulip_user_id = None 215 216 result = self.bot._check_initialization(message, self.handler) 216 217 assert result is False 217 218 assert len(self.handler.sent_messages) == 1 218 219 assert "Debug mode validation failed" in self.handler.sent_messages[0]["content"] 219 - 220 + 220 221 def test_debug_mode_dm_posting(self) -> None: 221 222 """Test that debug mode posts DMs instead of stream messages.""" 222 223 from unittest.mock import Mock 223 - from datetime import datetime 224 - from pydantic import HttpUrl 225 - 224 + 225 + 226 226 # Setup bot in debug mode 227 227 self.bot.debug_user = "testuser" 228 228 self.bot.debug_zulip_user_id = "test.user@example.com" 229 229 self.bot.git_store = Mock() 230 - 230 + 231 231 # Create a test entry 232 232 entry = create_test_entry() 233 - 233 + 234 234 # Mock the handler config 235 235 self.handler.config_info = { 236 236 "full_name": "Thicket Bot", 237 237 "email": "thicket-bot@example.com", 238 238 "site": "https://example.zulipchat.com" 239 239 } 240 - 240 + 241 241 # Mock git store user 242 242 mock_user = Mock() 243 243 mock_user.get_zulip_mention.return_value = "author.user" 244 244 self.bot.git_store.get_user.return_value = mock_user 245 - 245 + 246 246 # Post entry 247 247 self.bot._post_entry_to_zulip(entry, self.handler, "testauthor") 248 - 248 + 249 249 # Check that a DM was sent 250 250 assert len(self.handler.sent_messages) == 1 251 251 message = self.handler.sent_messages[0] 252 - 252 + 253 253 # Verify it's a DM 254 254 assert message["type"] == "private" 255 255 assert message["to"] == ["test.user@example.com"] ··· 264 264 def test_bot_tester_basic(self) -> None: 265 265 """Test basic bot tester functionality.""" 266 266 tester = BotTester() 267 - 267 + 268 268 # Test help command 269 269 responses = tester.send_command("help") 270 270 assert len(responses) == 1 ··· 273 273 def test_bot_tester_config(self) -> None: 274 274 """Test bot tester configuration.""" 275 275 tester = BotTester() 276 - 276 + 277 277 # Configure stream 278 278 responses = tester.send_command("config stream general") 279 279 tester.assert_response_contains("Stream set to") 280 - 280 + 281 281 # Configure topic 282 282 responses = tester.send_command("config topic test") 283 283 tester.assert_response_contains("Topic set to") ··· 285 285 def test_assert_response_contains(self) -> None: 286 286 """Test response assertion helper.""" 287 287 tester = BotTester() 288 - 288 + 289 289 # Send command 290 290 tester.send_command("help") 291 - 291 + 292 292 # This should pass 293 293 tester.assert_response_contains("Thicket Feed Bot") 294 - 294 + 295 295 # This should fail 296 296 with pytest.raises(AssertionError): 297 - tester.assert_response_contains("nonexistent text") 297 + tester.assert_response_contains("nonexistent text")
+15 -16
tests/test_models.py
··· 9 9 AtomEntry, 10 10 DuplicateMap, 11 11 FeedMetadata, 12 - GitStoreIndex, 13 12 ThicketConfig, 14 13 UserConfig, 15 14 UserMetadata, ··· 352 351 353 352 assert metadata.entry_count == original_count + 3 354 353 assert metadata.last_updated > original_time 355 - 354 + 356 355 def test_zulip_associations(self): 357 356 """Test Zulip association methods.""" 358 357 metadata = UserMetadata( ··· 361 360 created=datetime.now(), 362 361 last_updated=datetime.now(), 363 362 ) 364 - 363 + 365 364 # Test adding association 366 365 result = metadata.add_zulip_association("example.zulipchat.com", "alice") 367 366 assert result is True 368 367 assert len(metadata.zulip_associations) == 1 369 368 assert metadata.zulip_associations[0].server == "example.zulipchat.com" 370 369 assert metadata.zulip_associations[0].user_id == "alice" 371 - 370 + 372 371 # Test adding duplicate association 373 372 result = metadata.add_zulip_association("example.zulipchat.com", "alice") 374 373 assert result is False 375 374 assert len(metadata.zulip_associations) == 1 376 - 375 + 377 376 # Test adding different association 378 377 result = metadata.add_zulip_association("other.zulipchat.com", "alice") 379 378 assert result is True 380 379 assert len(metadata.zulip_associations) == 2 381 - 380 + 382 381 # Test get_zulip_mention 383 382 mention = metadata.get_zulip_mention("example.zulipchat.com") 384 383 assert mention == "alice" 385 - 384 + 386 385 mention = metadata.get_zulip_mention("other.zulipchat.com") 387 386 assert mention == "alice" 388 - 387 + 389 388 mention = metadata.get_zulip_mention("nonexistent.zulipchat.com") 390 389 assert mention is None 391 - 390 + 392 391 # Test removing association 393 392 result = metadata.remove_zulip_association("example.zulipchat.com", "alice") 394 393 assert result is True 395 394 assert len(metadata.zulip_associations) == 1 396 - 395 + 397 396 # Test removing non-existent association 398 397 result = metadata.remove_zulip_association("example.zulipchat.com", "alice") 399 398 assert result is False ··· 402 401 403 402 class TestZulipAssociation: 404 403 """Test ZulipAssociation model.""" 405 - 404 + 406 405 def test_valid_association(self): 407 406 """Test creating valid Zulip association.""" 408 407 assoc = ZulipAssociation( 409 408 server="example.zulipchat.com", 410 409 user_id="alice@example.com" 411 410 ) 412 - 411 + 413 412 assert assoc.server == "example.zulipchat.com" 414 413 assert assoc.user_id == "alice@example.com" 415 - 414 + 416 415 def test_association_hash(self): 417 416 """Test that associations are hashable.""" 418 417 assoc1 = ZulipAssociation( ··· 427 426 server="other.zulipchat.com", 428 427 user_id="alice" 429 428 ) 430 - 429 + 431 430 # Same associations should have same hash 432 431 assert hash(assoc1) == hash(assoc2) 433 - 432 + 434 433 # Different associations should have different hash 435 434 assert hash(assoc1) != hash(assoc3) 436 - 435 + 437 436 # Can be used in sets 438 437 assoc_set = {assoc1, assoc2, assoc3} 439 438 assert len(assoc_set) == 2 # assoc1 and assoc2 are considered the same