this repo has no description
1
fork

Configure Feed

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

Fix code quality issues and improve consistency

- Fix all ruff linting errors (28 issues resolved)
- Update type annotations to modern Python syntax (Dict -> dict, List -> list)
- Fix exception handling to use proper chaining (raise ... from err)
- Remove whitespace issues and improve formatting
- Add noqa comments for intentional import patterns
- Format all code with ruff formatter for consistency
- Ensure all Python files compile successfully

All code now passes comprehensive quality checks:
- ✅ Zero ruff linting errors
- ✅ Consistent code formatting
- ✅ Modern type annotations
- ✅ Proper exception handling
- ✅ All files compile successfully

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

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

+755 -384
+22 -23
src/thicket/bots/test_bot.py
··· 2 2 3 3 import json 4 4 from pathlib import Path 5 - from typing import Any, Dict, Optional 5 + from typing import Any, Optional 6 6 7 7 from ..models import AtomEntry 8 8 from .thicket_bot import ThicketBotHandler ··· 13 13 14 14 def __init__(self) -> None: 15 15 """Initialize mock bot handler.""" 16 - self.storage_data: Dict[str, str] = {} 17 - self.sent_messages: list[Dict[str, Any]] = [] 16 + self.storage_data: dict[str, str] = {} 17 + self.sent_messages: list[dict[str, Any]] = [] 18 18 self.config_info = { 19 19 "full_name": "Thicket Bot", 20 - "email": "thicket-bot@example.com" 20 + "email": "thicket-bot@example.com", 21 21 } 22 22 23 - def get_config_info(self) -> Dict[str, str]: 23 + def get_config_info(self) -> dict[str, str]: 24 24 """Return bot configuration info.""" 25 25 return self.config_info 26 26 27 - def send_reply(self, message: Dict[str, Any], content: str) -> None: 27 + def send_reply(self, message: dict[str, Any], content: str) -> None: 28 28 """Mock sending a reply.""" 29 29 reply = { 30 30 "type": "reply", 31 31 "to": message.get("sender_id"), 32 32 "content": content, 33 - "original_message": message 33 + "original_message": message, 34 34 } 35 35 self.sent_messages.append(reply) 36 36 37 - def send_message(self, message: Dict[str, Any]) -> None: 37 + def send_message(self, message: dict[str, Any]) -> None: 38 38 """Mock sending a message.""" 39 39 self.sent_messages.append(message) 40 40 41 41 @property 42 - def storage(self) -> 'MockStorage': 42 + def storage(self) -> "MockStorage": 43 43 """Return mock storage.""" 44 44 return MockStorage(self.storage_data) 45 45 ··· 47 47 class MockStorage: 48 48 """Mock storage for bot state.""" 49 49 50 - def __init__(self, storage_data: Dict[str, str]) -> None: 50 + def __init__(self, storage_data: dict[str, str]) -> None: 51 51 """Initialize with storage data.""" 52 52 self.storage_data = storage_data 53 53 54 - def __enter__(self) -> 'MockStorage': 54 + def __enter__(self) -> "MockStorage": 55 55 """Context manager entry.""" 56 56 return self 57 57 ··· 76 76 content: str, 77 77 sender: str = "Test User", 78 78 sender_id: int = 12345, 79 - message_type: str = "stream" 80 - ) -> Dict[str, Any]: 79 + message_type: str = "stream", 80 + ) -> dict[str, Any]: 81 81 """Create a test message for bot testing.""" 82 82 return { 83 83 "content": content, ··· 86 86 "type": message_type, 87 87 "timestamp": 1642694400, # 2022-01-20 12:00:00 UTC 88 88 "stream_id": 1, 89 - "subject": "test topic" 89 + "subject": "test topic", 90 90 } 91 91 92 92 93 93 def create_test_entry( 94 94 entry_id: str = "test-entry-1", 95 95 title: str = "Test Article", 96 - link: str = "https://example.com/test-article" 96 + link: str = "https://example.com/test-article", 97 97 ) -> AtomEntry: 98 98 """Create a test AtomEntry for testing.""" 99 99 from datetime import datetime ··· 108 108 published=datetime(2024, 1, 20, 10, 0, 0), 109 109 summary="This is a test article summary", 110 110 content="<p>This is test article content</p>", 111 - author={"name": "Test Author", "email": "author@example.com"} 111 + author={"name": "Test Author", "email": "author@example.com"}, 112 112 ) 113 113 114 114 ··· 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", 131 - topic: str = "test-topic" 128 + self, config_path: Path, stream: str = "test-stream", topic: str = "test-topic" 132 129 ) -> None: 133 130 """Configure the bot for testing.""" 134 131 # Set bot configuration ··· 137 134 "topic_name": topic, 138 135 "sync_interval": 300, 139 136 "max_entries_per_sync": 10, 140 - "config_path": str(config_path) 137 + "config_path": str(config_path), 141 138 } 142 139 143 140 self.handler.storage_data["bot_config"] = json.dumps(config_data) ··· 145 142 # Initialize bot 146 143 self.bot._load_bot_config(self.handler) 147 144 148 - def send_command(self, command: str, sender: str = "Test User") -> list[Dict[str, Any]]: 145 + def send_command( 146 + self, command: str, sender: str = "Test User" 147 + ) -> list[dict[str, Any]]: 149 148 """Send a command to the bot and return responses.""" 150 149 message = create_test_message(f"@thicket {command}", sender) 151 150 ··· 163 162 return self.handler.sent_messages[-1].get("content") 164 163 return None 165 164 166 - def get_last_message(self) -> Optional[Dict[str, Any]]: 165 + def get_last_message(self) -> Optional[dict[str, Any]]: 167 166 """Get the last sent message.""" 168 167 if self.handler.sent_messages: 169 168 return self.handler.sent_messages[-1]
+429 -187
src/thicket/bots/thicket_bot.py
··· 18 18 except ImportError: 19 19 # When run directly by zulip-bots, add the package to path 20 20 import sys 21 + 21 22 src_dir = Path(__file__).parent.parent.parent 22 23 if str(src_dir) not in sys.path: 23 24 sys.path.insert(0, str(src_dir)) ··· 134 135 self._send_help(message, bot_handler) 135 136 elif command == "status": 136 137 self._send_status(message, bot_handler, sender) 137 - elif command == "sync" and len(command_parts) > 1 and command_parts[1] == "now": 138 + elif ( 139 + command == "sync" 140 + and len(command_parts) > 1 141 + and command_parts[1] == "now" 142 + ): 138 143 self._handle_force_sync(message, bot_handler, sender) 139 144 elif command == "reset": 140 145 self._handle_reset_command(message, bot_handler, sender) 141 146 elif command == "config": 142 - self._handle_config_command(message, bot_handler, command_parts[1:], sender) 147 + self._handle_config_command( 148 + message, bot_handler, command_parts[1:], sender 149 + ) 143 150 elif command == "schedule": 144 151 self._handle_schedule_command(message, bot_handler, sender) 145 152 elif command == "claim": 146 - self._handle_claim_command(message, bot_handler, command_parts[1:], sender) 153 + self._handle_claim_command( 154 + message, bot_handler, command_parts[1:], sender 155 + ) 147 156 else: 148 - bot_handler.send_reply(message, f"Unknown command: {command}. Type `@mention help` for usage.") 157 + bot_handler.send_reply( 158 + message, 159 + f"Unknown command: {command}. Type `@mention help` for usage.", 160 + ) 149 161 except Exception as e: 150 162 self.logger.error(f"Error handling command '{command}': {e}") 151 163 bot_handler.send_reply(message, f"Error processing command: {str(e)}") ··· 155 167 try: 156 168 # Get bot's actual name from Zulip 157 169 bot_info = bot_handler._client.get_profile() 158 - if bot_info.get('result') == 'success': 159 - bot_name = bot_info.get('full_name', '').lower() 170 + if bot_info.get("result") == "success": 171 + bot_name = bot_info.get("full_name", "").lower() 160 172 if bot_name: 161 - return f"@{bot_name}" in content.lower() or f"@**{bot_name}**" in content.lower() 173 + return ( 174 + f"@{bot_name}" in content.lower() 175 + or f"@**{bot_name}**" in content.lower() 176 + ) 162 177 except Exception as e: 163 178 self.logger.debug(f"Could not get bot profile: {e}") 164 179 ··· 172 187 try: 173 188 # Get bot's actual name from Zulip 174 189 bot_info = bot_handler._client.get_profile() 175 - if bot_info.get('result') == 'success': 176 - bot_name = bot_info.get('full_name', '') 190 + if bot_info.get("result") == "success": 191 + bot_name = bot_info.get("full_name", "") 177 192 if bot_name: 178 193 # Remove @bot_name or @**bot_name** 179 194 escaped_name = re.escape(bot_name) 180 - content = re.sub(rf'@(?:\*\*)?{escaped_name}(?:\*\*)?', '', content, flags=re.IGNORECASE).strip() 195 + content = re.sub( 196 + rf"@(?:\*\*)?{escaped_name}(?:\*\*)?", 197 + "", 198 + content, 199 + flags=re.IGNORECASE, 200 + ).strip() 181 201 return content 182 202 except Exception as e: 183 203 self.logger.debug(f"Could not get bot profile for mention cleaning: {e}") 184 204 185 205 # Fallback to removing @thicket 186 - content = re.sub(r'@(?:\*\*)?thicket(?:\*\*)?', '', content, flags=re.IGNORECASE).strip() 206 + content = re.sub( 207 + r"@(?:\*\*)?thicket(?:\*\*)?", "", content, flags=re.IGNORECASE 208 + ).strip() 187 209 return content 188 210 189 211 def _send_help(self, message: dict[str, Any], bot_handler: BotHandler) -> None: 190 212 """Send help message.""" 191 213 bot_handler.send_reply(message, self.usage()) 192 214 193 - def _send_status(self, message: dict[str, Any], bot_handler: BotHandler, sender: str) -> None: 215 + def _send_status( 216 + self, message: dict[str, Any], bot_handler: BotHandler, sender: str 217 + ) -> None: 194 218 """Send bot status information.""" 195 219 status_lines = [ 196 220 f"**Thicket Bot Status** (requested by {sender})", ··· 199 223 200 224 # Debug mode status 201 225 if self.debug_user: 202 - status_lines.extend([ 203 - "🐛 **Debug Mode:** ENABLED", 204 - f"🎯 **Debug User:** {self.debug_user}", 205 - "", 206 - ]) 226 + status_lines.extend( 227 + [ 228 + "🐛 **Debug Mode:** ENABLED", 229 + f"🎯 **Debug User:** {self.debug_user}", 230 + "", 231 + ] 232 + ) 207 233 else: 208 - status_lines.extend([ 209 - f"📍 **Stream:** {self.stream_name or 'Not configured'}", 210 - f"📝 **Topic:** {self.topic_name or 'Not configured'}", 211 - "", 212 - ]) 234 + status_lines.extend( 235 + [ 236 + f"📍 **Stream:** {self.stream_name or 'Not configured'}", 237 + f"📝 **Topic:** {self.topic_name or 'Not configured'}", 238 + "", 239 + ] 240 + ) 213 241 214 - status_lines.extend([ 215 - f"⏱️ **Sync Interval:** {self.sync_interval}s ({self.sync_interval // 60}m {self.sync_interval % 60}s)", 216 - f"📊 **Max Entries/Sync:** {self.max_entries_per_sync}", 217 - f"📁 **Config Path:** {self.config_path or 'Not configured'}", 218 - "", 219 - f"📄 **Tracked Entries:** {len(self.posted_entries)}", 220 - f"🔄 **Catchup Mode:** {'Active (first run)' if len(self.posted_entries) == 0 else 'Inactive'}", 221 - f"✅ **Thicket Initialized:** {'Yes' if self.git_store else 'No'}", 222 - "", 223 - self._get_schedule_info(), 224 - ]) 242 + status_lines.extend( 243 + [ 244 + f"⏱️ **Sync Interval:** {self.sync_interval}s ({self.sync_interval // 60}m {self.sync_interval % 60}s)", 245 + f"📊 **Max Entries/Sync:** {self.max_entries_per_sync}", 246 + f"📁 **Config Path:** {self.config_path or 'Not configured'}", 247 + "", 248 + f"📄 **Tracked Entries:** {len(self.posted_entries)}", 249 + f"🔄 **Catchup Mode:** {'Active (first run)' if len(self.posted_entries) == 0 else 'Inactive'}", 250 + f"✅ **Thicket Initialized:** {'Yes' if self.git_store else 'No'}", 251 + "", 252 + self._get_schedule_info(), 253 + ] 254 + ) 225 255 226 256 bot_handler.send_reply(message, "\n".join(status_lines)) 227 257 228 - def _handle_force_sync(self, message: dict[str, Any], bot_handler: BotHandler, sender: str) -> None: 258 + def _handle_force_sync( 259 + self, message: dict[str, Any], bot_handler: BotHandler, sender: str 260 + ) -> None: 229 261 """Handle immediate sync request.""" 230 262 if not self._check_initialization(message, bot_handler): 231 263 return 232 264 233 - bot_handler.send_reply(message, f"🔄 Starting immediate sync... (requested by {sender})") 265 + bot_handler.send_reply( 266 + message, f"🔄 Starting immediate sync... (requested by {sender})" 267 + ) 234 268 235 269 try: 236 270 new_entries = self._perform_sync(bot_handler) 237 271 bot_handler.send_reply( 238 - message, 239 - f"✅ Sync completed! Found {len(new_entries)} new entries." 272 + message, f"✅ Sync completed! Found {len(new_entries)} new entries." 240 273 ) 241 274 except Exception as e: 242 275 self.logger.error(f"Force sync failed: {e}") 243 276 bot_handler.send_reply(message, f"❌ Sync failed: {str(e)}") 244 277 245 - def _handle_reset_command(self, message: dict[str, Any], bot_handler: BotHandler, sender: str) -> None: 278 + def _handle_reset_command( 279 + self, message: dict[str, Any], bot_handler: BotHandler, sender: str 280 + ) -> None: 246 281 """Handle reset command to clear posted entries tracking.""" 247 282 try: 248 283 self.posted_entries.clear() 249 284 self._save_posted_entries(bot_handler) 250 285 bot_handler.send_reply( 251 286 message, 252 - f"✅ Posting history reset! Recent entries will be posted on next sync. (requested by {sender})" 287 + f"✅ Posting history reset! Recent entries will be posted on next sync. (requested by {sender})", 253 288 ) 254 289 self.logger.info(f"Posted entries tracking reset by {sender}") 255 290 except Exception as e: 256 291 self.logger.error(f"Reset failed: {e}") 257 292 bot_handler.send_reply(message, f"❌ Reset failed: {str(e)}") 258 293 259 - def _handle_schedule_command(self, message: dict[str, Any], bot_handler: BotHandler, sender: str) -> None: 294 + def _handle_schedule_command( 295 + self, message: dict[str, Any], bot_handler: BotHandler, sender: str 296 + ) -> None: 260 297 """Handle schedule query command.""" 261 298 schedule_info = self._get_schedule_info() 262 299 bot_handler.send_reply( 263 300 message, 264 - f"**Thicket Bot Schedule** (requested by {sender})\n\n{schedule_info}" 301 + f"**Thicket Bot Schedule** (requested by {sender})\n\n{schedule_info}", 265 302 ) 266 303 267 304 def _handle_claim_command( ··· 269 306 message: dict[str, Any], 270 307 bot_handler: BotHandler, 271 308 args: list[str], 272 - sender: str 309 + sender: str, 273 310 ) -> None: 274 311 """Handle username claiming command.""" 275 312 if not args: ··· 286 323 sender_email = message.get("sender_email") 287 324 288 325 if not sender_user_id or not sender_email: 289 - bot_handler.send_reply(message, "❌ Could not determine your Zulip user information.") 326 + bot_handler.send_reply( 327 + message, "❌ Could not determine your Zulip user information." 328 + ) 290 329 return 291 330 292 331 try: ··· 295 334 server_url = zulip_site_url.replace("https://", "").replace("http://", "") 296 335 297 336 if not server_url: 298 - bot_handler.send_reply(message, "❌ Could not determine Zulip server URL.") 337 + bot_handler.send_reply( 338 + message, "❌ Could not determine Zulip server URL." 339 + ) 299 340 return 300 341 301 342 # Check if username exists in thicket ··· 303 344 if not user: 304 345 bot_handler.send_reply( 305 346 message, 306 - f"❌ Username `{username}` not found in thicket. Available users: {', '.join(self.git_store.list_users())}" 347 + f"❌ Username `{username}` not found in thicket. Available users: {', '.join(self.git_store.list_users())}", 307 348 ) 308 349 return 309 350 ··· 311 352 existing_zulip_id = user.get_zulip_mention(server_url) 312 353 if existing_zulip_id: 313 354 # 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): 355 + if existing_zulip_id == sender_email or str(existing_zulip_id) == str( 356 + sender_user_id 357 + ): 315 358 bot_handler.send_reply( 316 359 message, 317 - f"✅ Username `{username}` is already claimed by you on {server_url}!" 360 + f"✅ Username `{username}` is already claimed by you on {server_url}!", 318 361 ) 319 362 else: 320 363 bot_handler.send_reply( 321 364 message, 322 - f"❌ Username `{username}` is already claimed by another user on {server_url}." 365 + f"❌ Username `{username}` is already claimed by another user on {server_url}.", 323 366 ) 324 367 return 325 368 326 369 # Claim the username - prefer email for consistency 327 - success = self.git_store.add_zulip_association(username, server_url, sender_email) 370 + success = self.git_store.add_zulip_association( 371 + username, server_url, sender_email 372 + ) 328 373 329 374 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." 375 + reply_msg = ( 376 + f"🎉 Successfully claimed username `{username}` for **{sender}** on {server_url}!\n" 377 + + "You will now be mentioned when new articles are posted from this user's feeds." 378 + ) 332 379 bot_handler.send_reply(message, reply_msg) 333 380 334 381 # 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): 382 + if ( 383 + self.username_claim_notifications 384 + and not self.debug_user 385 + and self.stream_name 386 + and self.topic_name 387 + ): 338 388 try: 339 389 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 - }) 390 + bot_handler.send_message( 391 + { 392 + "type": "stream", 393 + "to": self.stream_name, 394 + "subject": self.topic_name, 395 + "content": notification_msg, 396 + } 397 + ) 346 398 except Exception as e: 347 - self.logger.error(f"Failed to send username claim notification: {e}") 399 + self.logger.error( 400 + f"Failed to send username claim notification: {e}" 401 + ) 348 402 349 - self.logger.info(f"User {sender} ({sender_email}) claimed username {username} on {server_url}") 403 + self.logger.info( 404 + f"User {sender} ({sender_email}) claimed username {username} on {server_url}" 405 + ) 350 406 else: 351 407 bot_handler.send_reply( 352 408 message, 353 - f"❌ Failed to claim username `{username}`. This shouldn't happen - please contact an administrator." 409 + f"❌ Failed to claim username `{username}`. This shouldn't happen - please contact an administrator.", 354 410 ) 355 411 356 412 except Exception as e: ··· 362 418 message: dict[str, Any], 363 419 bot_handler: BotHandler, 364 420 args: list[str], 365 - sender: str 421 + sender: str, 366 422 ) -> None: 367 423 """Handle configuration commands.""" 368 424 if len(args) < 2: 369 - bot_handler.send_reply(message, "Usage: `@mention config <setting> <value>`") 425 + bot_handler.send_reply( 426 + message, "Usage: `@mention config <setting> <value>`" 427 + ) 370 428 return 371 429 372 430 setting = args[0].lower() ··· 376 434 old_value = self.stream_name 377 435 self.stream_name = value 378 436 self._save_bot_config(bot_handler) 379 - bot_handler.send_reply(message, f"✅ Stream set to: **{value}** (by {sender})") 380 - self._send_config_change_notification(bot_handler, sender, "stream", old_value, value) 437 + bot_handler.send_reply( 438 + message, f"✅ Stream set to: **{value}** (by {sender})" 439 + ) 440 + self._send_config_change_notification( 441 + bot_handler, sender, "stream", old_value, value 442 + ) 381 443 382 444 elif setting == "topic": 383 445 old_value = self.topic_name 384 446 self.topic_name = value 385 447 self._save_bot_config(bot_handler) 386 - bot_handler.send_reply(message, f"✅ Topic set to: **{value}** (by {sender})") 387 - self._send_config_change_notification(bot_handler, sender, "topic", old_value, value) 448 + bot_handler.send_reply( 449 + message, f"✅ Topic set to: **{value}** (by {sender})" 450 + ) 451 + self._send_config_change_notification( 452 + bot_handler, sender, "topic", old_value, value 453 + ) 388 454 389 455 elif setting == "interval": 390 456 try: 391 457 interval = int(value) 392 458 if interval < 60: 393 - bot_handler.send_reply(message, "❌ Interval must be at least 60 seconds") 459 + bot_handler.send_reply( 460 + message, "❌ Interval must be at least 60 seconds" 461 + ) 394 462 return 395 463 old_value = self.sync_interval 396 464 self.sync_interval = interval 397 465 self._save_bot_config(bot_handler) 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") 466 + bot_handler.send_reply( 467 + message, f"✅ Sync interval set to: **{interval}s** (by {sender})" 468 + ) 469 + self._send_config_change_notification( 470 + bot_handler, 471 + sender, 472 + "sync interval", 473 + f"{old_value}s", 474 + f"{interval}s", 475 + ) 400 476 except ValueError: 401 - bot_handler.send_reply(message, "❌ Invalid interval value. Must be a number of seconds.") 477 + bot_handler.send_reply( 478 + message, "❌ Invalid interval value. Must be a number of seconds." 479 + ) 402 480 403 481 elif setting == "max_entries": 404 482 try: 405 483 max_entries = int(value) 406 484 if max_entries < 1 or max_entries > 50: 407 - bot_handler.send_reply(message, "❌ Max entries must be between 1 and 50") 485 + bot_handler.send_reply( 486 + message, "❌ Max entries must be between 1 and 50" 487 + ) 408 488 return 409 489 old_value = self.max_entries_per_sync 410 490 self.max_entries_per_sync = max_entries 411 491 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)) 492 + bot_handler.send_reply( 493 + message, 494 + f"✅ Max entries per sync set to: **{max_entries}** (by {sender})", 495 + ) 496 + self._send_config_change_notification( 497 + bot_handler, 498 + sender, 499 + "max entries per sync", 500 + str(old_value), 501 + str(max_entries), 502 + ) 414 503 except ValueError: 415 - bot_handler.send_reply(message, "❌ Invalid max entries value. Must be a number.") 504 + bot_handler.send_reply( 505 + message, "❌ Invalid max entries value. Must be a number." 506 + ) 416 507 417 508 else: 418 509 bot_handler.send_reply( 419 510 message, 420 - f"❌ Unknown setting: {setting}. Available: stream, topic, interval, max_entries" 511 + f"❌ Unknown setting: {setting}. Available: stream, topic, interval, max_entries", 421 512 ) 422 513 423 514 def _load_bot_config(self, bot_handler: BotHandler) -> None: ··· 466 557 if "bot" in config: 467 558 bot_section = config["bot"] 468 559 self.sync_interval = bot_section.getint("sync_interval", 300) 469 - self.max_entries_per_sync = bot_section.getint("max_entries_per_sync", 10) 560 + self.max_entries_per_sync = bot_section.getint( 561 + "max_entries_per_sync", 10 562 + ) 470 563 self.rate_limit_delay = bot_section.getint("rate_limit_delay", 5) 471 564 self.posts_per_batch = bot_section.getint("posts_per_batch", 5) 472 565 ··· 484 577 485 578 if "notifications" in config: 486 579 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) 580 + self.config_change_notifications = notifications_section.getboolean( 581 + "config_change_notifications", True 582 + ) 583 + self.username_claim_notifications = notifications_section.getboolean( 584 + "username_claim_notifications", True 585 + ) 489 586 490 587 self.logger.info(f"Loaded configuration from {botrc_path}") 491 588 ··· 500 597 501 598 # Load thicket configuration 502 599 import yaml 600 + 503 601 with open(self.config_path) as f: 504 602 config_data = yaml.safe_load(f) 505 603 self.config = ThicketConfig(**config_data) ··· 529 627 530 628 zulip_user_id = user.get_zulip_mention(server_url) 531 629 if not zulip_user_id: 532 - raise ValueError(f"User '{self.debug_user}' has no Zulip association for server '{server_url}'") 630 + raise ValueError( 631 + f"User '{self.debug_user}' has no Zulip association for server '{server_url}'" 632 + ) 533 633 534 634 # Try to look up the actual Zulip user ID from the email address 535 635 # But don't fail if we can't - we'll try again when sending messages ··· 537 637 if actual_user_id and actual_user_id != zulip_user_id: 538 638 # Successfully resolved to numeric ID 539 639 self.debug_zulip_user_id = actual_user_id 540 - self.logger.info(f"Debug mode enabled: Will send DMs to {self.debug_user} (email: {zulip_user_id}, user_id: {actual_user_id}) on {server_url}") 640 + self.logger.info( 641 + f"Debug mode enabled: Will send DMs to {self.debug_user} (email: {zulip_user_id}, user_id: {actual_user_id}) on {server_url}" 642 + ) 541 643 else: 542 644 # Keep the email address, will resolve later when sending 543 645 self.debug_zulip_user_id = zulip_user_id 544 - self.logger.info(f"Debug mode enabled: Will send DMs to {self.debug_user} ({zulip_user_id}) on {server_url} (will resolve user ID when sending)") 646 + self.logger.info( 647 + f"Debug mode enabled: Will send DMs to {self.debug_user} ({zulip_user_id}) on {server_url} (will resolve user ID when sending)" 648 + ) 545 649 546 - def _lookup_zulip_user_id(self, bot_handler: BotHandler, email_or_id: str) -> Optional[str]: 650 + def _lookup_zulip_user_id( 651 + self, bot_handler: BotHandler, email_or_id: str 652 + ) -> Optional[str]: 547 653 """Look up Zulip user ID from email address or return the ID if it's already numeric.""" 548 654 # If it's already a numeric user ID, return it 549 655 if email_or_id.isdigit(): ··· 558 664 # First try the get_user_by_email API if available 559 665 try: 560 666 user_result = client.get_user_by_email(email_or_id) 561 - if user_result.get('result') == 'success': 562 - user_data = user_result.get('user', {}) 563 - user_id = user_data.get('user_id') 667 + if user_result.get("result") == "success": 668 + user_data = user_result.get("user", {}) 669 + user_id = user_data.get("user_id") 564 670 if user_id: 565 - self.logger.info(f"Found user ID {user_id} for '{email_or_id}' via get_user_by_email API") 671 + self.logger.info( 672 + f"Found user ID {user_id} for '{email_or_id}' via get_user_by_email API" 673 + ) 566 674 return str(user_id) 567 675 except (AttributeError, Exception): 568 676 pass 569 677 570 678 # Fallback: Get all users and search through them 571 679 users_result = client.get_users() 572 - if users_result.get('result') == 'success': 573 - for user in users_result['members']: 574 - user_email = user.get('email', '') 575 - delivery_email = user.get('delivery_email', '') 680 + if users_result.get("result") == "success": 681 + for user in users_result["members"]: 682 + user_email = user.get("email", "") 683 + delivery_email = user.get("delivery_email", "") 576 684 577 - if (user_email == email_or_id or 578 - delivery_email == email_or_id or 579 - str(user.get('user_id')) == email_or_id): 580 - user_id = user.get('user_id') 685 + if ( 686 + user_email == email_or_id 687 + or delivery_email == email_or_id 688 + or str(user.get("user_id")) == email_or_id 689 + ): 690 + user_id = user.get("user_id") 581 691 return str(user_id) 582 692 583 - self.logger.error(f"No user found with identifier '{email_or_id}'. Searched {len(users_result['members'])} users.") 693 + self.logger.error( 694 + f"No user found with identifier '{email_or_id}'. Searched {len(users_result['members'])} users." 695 + ) 584 696 return None 585 697 else: 586 - self.logger.error(f"Failed to get users: {users_result.get('msg', 'Unknown error')}") 698 + self.logger.error( 699 + f"Failed to get users: {users_result.get('msg', 'Unknown error')}" 700 + ) 587 701 return None 588 702 589 703 except Exception as e: 590 704 self.logger.error(f"Error looking up user ID for '{email_or_id}': {e}") 591 705 return None 592 706 593 - def _lookup_zulip_user_info(self, bot_handler: BotHandler, email_or_id: str) -> tuple[Optional[str], Optional[str]]: 707 + def _lookup_zulip_user_info( 708 + self, bot_handler: BotHandler, email_or_id: str 709 + ) -> tuple[Optional[str], Optional[str]]: 594 710 """Look up both Zulip user ID and full name from email address.""" 595 711 if email_or_id.isdigit(): 596 712 return email_or_id, None ··· 603 719 # Try get_user_by_email API first 604 720 try: 605 721 user_result = client.get_user_by_email(email_or_id) 606 - if user_result.get('result') == 'success': 607 - user_data = user_result.get('user', {}) 608 - user_id = user_data.get('user_id') 609 - full_name = user_data.get('full_name', '') 722 + if user_result.get("result") == "success": 723 + user_data = user_result.get("user", {}) 724 + user_id = user_data.get("user_id") 725 + full_name = user_data.get("full_name", "") 610 726 if user_id: 611 727 return str(user_id), full_name 612 728 except AttributeError: ··· 614 730 615 731 # Fallback: search all users 616 732 users_result = client.get_users() 617 - if users_result.get('result') == 'success': 618 - for user in users_result['members']: 619 - if (user.get('email') == email_or_id or 620 - user.get('delivery_email') == email_or_id): 621 - return str(user.get('user_id')), user.get('full_name', '') 733 + if users_result.get("result") == "success": 734 + for user in users_result["members"]: 735 + if ( 736 + user.get("email") == email_or_id 737 + or user.get("delivery_email") == email_or_id 738 + ): 739 + return str(user.get("user_id")), user.get("full_name", "") 622 740 623 741 return None, None 624 742 ··· 639 757 def _save_posted_entries(self, bot_handler: BotHandler) -> None: 640 758 """Save the set of posted entries.""" 641 759 try: 642 - bot_handler.storage.put("posted_entries", json.dumps(list(self.posted_entries))) 760 + bot_handler.storage.put( 761 + "posted_entries", json.dumps(list(self.posted_entries)) 762 + ) 643 763 except Exception as e: 644 764 self.logger.error(f"Error saving posted entries: {e}") 645 765 646 - def _check_initialization(self, message: dict[str, Any], bot_handler: BotHandler) -> bool: 766 + def _check_initialization( 767 + self, message: dict[str, Any], bot_handler: BotHandler 768 + ) -> bool: 647 769 """Check if thicket is properly initialized.""" 648 770 if not self.git_store or not self.config: 649 771 bot_handler.send_reply( 650 - message, 651 - "❌ Thicket not initialized. Please check configuration." 772 + message, "❌ Thicket not initialized. Please check configuration." 652 773 ) 653 774 return False 654 775 ··· 659 780 if not self.stream_name or not self.topic_name: 660 781 bot_handler.send_reply( 661 782 message, 662 - "❌ Stream and topic must be configured first. Use `@mention config stream <name>` and `@mention config topic <name>`" 783 + "❌ Stream and topic must be configured first. Use `@mention config stream <name>` and `@mention config topic <name>`", 663 784 ) 664 785 return False 665 786 ··· 667 788 668 789 def _schedule_sync(self, bot_handler: BotHandler) -> None: 669 790 """Schedule periodic sync operations.""" 791 + 670 792 def sync_loop(): 671 793 while True: 672 794 try: 673 795 # Check if we can sync 674 - can_sync = (self.git_store and 675 - ((self.stream_name and self.topic_name) or 676 - self.debug_user)) 796 + can_sync = self.git_store and ( 797 + (self.stream_name and self.topic_name) or self.debug_user 798 + ) 677 799 678 800 if can_sync: 679 801 self._perform_sync(bot_handler) ··· 685 807 686 808 # Start background thread 687 809 import threading 810 + 688 811 sync_thread = threading.Thread(target=sync_loop, daemon=True) 689 812 sync_thread.start() 690 813 ··· 708 831 asyncio.set_event_loop(loop) 709 832 try: 710 833 new_count, _ = loop.run_until_complete( 711 - sync_feed(self.git_store, username, str(feed_url), dry_run=False) 834 + sync_feed( 835 + self.git_store, username, str(feed_url), dry_run=False 836 + ) 712 837 ) 713 838 714 839 entries_to_check = [] 715 840 716 841 if new_count > 0: 717 842 # Get the newly added entries 718 - entries_to_check = self.git_store.list_entries(username, limit=new_count) 843 + entries_to_check = self.git_store.list_entries( 844 + username, limit=new_count 845 + ) 719 846 720 847 # Always check for catchup mode on first run 721 848 if is_first_run: 722 849 # Catchup mode: get configured number of entries on first run 723 - catchup_entries = self.git_store.list_entries(username, limit=self.catchup_entries) 724 - entries_to_check = catchup_entries if not entries_to_check else entries_to_check 850 + catchup_entries = self.git_store.list_entries( 851 + username, limit=self.catchup_entries 852 + ) 853 + entries_to_check = ( 854 + catchup_entries 855 + if not entries_to_check 856 + else entries_to_check 857 + ) 725 858 726 859 for entry in entries_to_check: 727 860 entry_key = f"{username}:{entry.id}" ··· 734 867 loop.close() 735 868 736 869 except Exception as e: 737 - self.logger.error(f"Error syncing feed {feed_url} for user {username}: {e}") 870 + self.logger.error( 871 + f"Error syncing feed {feed_url} for user {username}: {e}" 872 + ) 738 873 739 874 if len(new_entries) >= self.max_entries_per_sync: 740 875 break ··· 749 884 posted_count += 1 750 885 751 886 # Rate limiting: pause after configured number of messages 752 - if posted_count % self.posts_per_batch == 0 and i < len(new_entries) - 1: 887 + if ( 888 + posted_count % self.posts_per_batch == 0 889 + and i < len(new_entries) - 1 890 + ): 753 891 time.sleep(self.rate_limit_delay) 754 892 755 893 self._save_posted_entries(bot_handler) ··· 759 897 760 898 return [entry for entry, _ in new_entries] 761 899 762 - def _post_entry_to_zulip(self, entry: AtomEntry, bot_handler: BotHandler, username: str) -> None: 900 + def _post_entry_to_zulip( 901 + self, entry: AtomEntry, bot_handler: BotHandler, username: str 902 + ) -> None: 763 903 """Post a single entry to the configured Zulip stream/topic or debug user DM.""" 764 904 try: 765 905 # Get current Zulip server from environment ··· 774 914 zulip_user_id = user.get_zulip_mention(server_url) 775 915 if zulip_user_id: 776 916 # Look up the actual Zulip full name for proper @mention 777 - _, zulip_full_name = self._lookup_zulip_user_info(bot_handler, zulip_user_id) 917 + _, zulip_full_name = self._lookup_zulip_user_info( 918 + bot_handler, zulip_user_id 919 + ) 778 920 display_name = zulip_full_name or user.display_name or username 779 921 780 922 # Check if author is different from the user - avoid redundancy ··· 786 928 787 929 published_info = "" 788 930 if entry.published: 789 - published_info = f" • {entry.published.strftime('%Y-%m-%d')}" 931 + published_info = ( 932 + f" • {entry.published.strftime('%Y-%m-%d')}" 933 + ) 790 934 791 935 mention_info = f"@**{display_name}** posted{author_info}{published_info}:\n\n" 792 936 ··· 805 949 if entry.published: 806 950 published_info = f" • {entry.published.strftime('%Y-%m-%d')}" 807 951 808 - mention_info = f"**{display_name}** posted{author_info}{published_info}:\n\n" 952 + mention_info = ( 953 + f"**{display_name}** posted{author_info}{published_info}:\n\n" 954 + ) 809 955 810 956 # Format the message with HTML processing 811 957 message_lines = [ ··· 831 977 user_id_to_use = self.debug_zulip_user_id 832 978 if not user_id_to_use.isdigit(): 833 979 # Need to look up the numeric ID 834 - resolved_id = self._lookup_zulip_user_id(bot_handler, user_id_to_use) 980 + resolved_id = self._lookup_zulip_user_id( 981 + bot_handler, user_id_to_use 982 + ) 835 983 if resolved_id: 836 984 user_id_to_use = resolved_id 837 - self.logger.debug(f"Resolved {self.debug_zulip_user_id} to user ID {user_id_to_use}") 985 + self.logger.debug( 986 + f"Resolved {self.debug_zulip_user_id} to user ID {user_id_to_use}" 987 + ) 838 988 else: 839 - self.logger.error(f"Could not resolve user ID for {self.debug_zulip_user_id}") 989 + self.logger.error( 990 + f"Could not resolve user ID for {self.debug_zulip_user_id}" 991 + ) 840 992 return 841 993 842 994 try: 843 995 # For private messages, user_id needs to be an integer, not string 844 996 user_id_int = int(user_id_to_use) 845 - bot_handler.send_message({ 846 - "type": "private", 847 - "to": [user_id_int], # Use integer user ID 848 - "content": debug_message 849 - }) 997 + bot_handler.send_message( 998 + { 999 + "type": "private", 1000 + "to": [user_id_int], # Use integer user ID 1001 + "content": debug_message, 1002 + } 1003 + ) 850 1004 except ValueError: 851 1005 # If conversion to int fails, user_id_to_use might be an email 852 1006 try: 853 - bot_handler.send_message({ 854 - "type": "private", 855 - "to": [user_id_to_use], # Try as string (email) 856 - "content": debug_message 857 - }) 1007 + bot_handler.send_message( 1008 + { 1009 + "type": "private", 1010 + "to": [user_id_to_use], # Try as string (email) 1011 + "content": debug_message, 1012 + } 1013 + ) 858 1014 except Exception as e2: 859 - self.logger.error(f"Failed to send DM to {self.debug_user} (tried both int and string): {e2}") 1015 + self.logger.error( 1016 + f"Failed to send DM to {self.debug_user} (tried both int and string): {e2}" 1017 + ) 860 1018 return 861 1019 except Exception as e: 862 - self.logger.error(f"Failed to send DM to {self.debug_user} ({user_id_to_use}): {e}") 1020 + self.logger.error( 1021 + f"Failed to send DM to {self.debug_user} ({user_id_to_use}): {e}" 1022 + ) 863 1023 return 864 - self.logger.info(f"Posted entry to debug user {self.debug_user}: {entry.title}") 1024 + self.logger.info( 1025 + f"Posted entry to debug user {self.debug_user}: {entry.title}" 1026 + ) 865 1027 else: 866 1028 # Normal mode: send to stream/topic 867 - bot_handler.send_message({ 868 - "type": "stream", 869 - "to": self.stream_name, 870 - "subject": self.topic_name, 871 - "content": message_content 872 - }) 873 - self.logger.info(f"Posted entry to stream: {entry.title} (user: {username})") 1029 + bot_handler.send_message( 1030 + { 1031 + "type": "stream", 1032 + "to": self.stream_name, 1033 + "subject": self.topic_name, 1034 + "content": message_content, 1035 + } 1036 + ) 1037 + self.logger.info( 1038 + f"Posted entry to stream: {entry.title} (user: {username})" 1039 + ) 874 1040 875 1041 except Exception as e: 876 1042 self.logger.error(f"Error posting entry to Zulip: {e}") ··· 889 1055 html_content, 890 1056 heading_style="ATX", # Use # for headings (but we'll post-process these) 891 1057 bullets="-", # Use - for bullets 892 - convert=['a', 'b', 'strong', 'i', 'em', 'code', 'pre', 'p', 'br', 'ul', 'ol', 'li', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6'] 1058 + convert=[ 1059 + "a", 1060 + "b", 1061 + "strong", 1062 + "i", 1063 + "em", 1064 + "code", 1065 + "pre", 1066 + "p", 1067 + "br", 1068 + "ul", 1069 + "ol", 1070 + "li", 1071 + "h1", 1072 + "h2", 1073 + "h3", 1074 + "h4", 1075 + "h5", 1076 + "h6", 1077 + ], 893 1078 ).strip() 894 1079 895 1080 # Post-process to convert headings to bold for compact summaries 896 1081 import re 1082 + 897 1083 # Convert markdown headers to bold with period 898 - markdown = re.sub(r'^#{1,6}\s*(.+)$', r'**\1.**', markdown, flags=re.MULTILINE) 1084 + markdown = re.sub( 1085 + r"^#{1,6}\s*(.+)$", r"**\1.**", markdown, flags=re.MULTILINE 1086 + ) 899 1087 900 1088 # Clean up excessive newlines and make more compact 901 - markdown = re.sub(r'\n\s*\n\s*\n+', ' ', markdown) # Multiple newlines become space 902 - markdown = re.sub(r'\n\s*\n', '. ', markdown) # Double newlines become sentence breaks 903 - markdown = re.sub(r'\n', ' ', markdown) # Single newlines become spaces 1089 + markdown = re.sub( 1090 + r"\n\s*\n\s*\n+", " ", markdown 1091 + ) # Multiple newlines become space 1092 + markdown = re.sub( 1093 + r"\n\s*\n", ". ", markdown 1094 + ) # Double newlines become sentence breaks 1095 + markdown = re.sub(r"\n", " ", markdown) # Single newlines become spaces 904 1096 905 1097 # Clean up double periods and excessive whitespace 906 - markdown = re.sub(r'\.\.+', '.', markdown) 907 - markdown = re.sub(r'\s+', ' ', markdown) 1098 + markdown = re.sub(r"\.\.+", ".", markdown) 1099 + markdown = re.sub(r"\s+", " ", markdown) 908 1100 return markdown.strip() 909 1101 910 1102 except ImportError: 911 1103 # Fallback: manual HTML processing 912 1104 import re 1105 + 913 1106 content = html_content 914 1107 915 1108 # Convert headings to bold with periods for compact summaries 916 - content = re.sub(r'<h[1-6](?:\s[^>]*)?>([^<]*)</h[1-6]>', r'**\1.** ', content, flags=re.IGNORECASE) 1109 + content = re.sub( 1110 + r"<h[1-6](?:\s[^>]*)?>([^<]*)</h[1-6]>", 1111 + r"**\1.** ", 1112 + content, 1113 + flags=re.IGNORECASE, 1114 + ) 917 1115 918 1116 # Convert common HTML elements to Markdown 919 - content = re.sub(r'<(?:strong|b)(?:\s[^>]*)?>([^<]*)</(?:strong|b)>', r'**\1**', content, flags=re.IGNORECASE) 920 - content = re.sub(r'<(?:em|i)(?:\s[^>]*)?>([^<]*)</(?:em|i)>', r'*\1*', content, flags=re.IGNORECASE) 921 - content = re.sub(r'<code(?:\s[^>]*)?>([^<]*)</code>', r'`\1`', content, flags=re.IGNORECASE) 922 - content = re.sub(r'<a(?:\s[^>]*?)?\s*href=["\']([^"\']*)["\'](?:\s[^>]*)?>([^<]*)</a>', r'[\2](\1)', content, flags=re.IGNORECASE) 1117 + content = re.sub( 1118 + r"<(?:strong|b)(?:\s[^>]*)?>([^<]*)</(?:strong|b)>", 1119 + r"**\1**", 1120 + content, 1121 + flags=re.IGNORECASE, 1122 + ) 1123 + content = re.sub( 1124 + r"<(?:em|i)(?:\s[^>]*)?>([^<]*)</(?:em|i)>", 1125 + r"*\1*", 1126 + content, 1127 + flags=re.IGNORECASE, 1128 + ) 1129 + content = re.sub( 1130 + r"<code(?:\s[^>]*)?>([^<]*)</code>", 1131 + r"`\1`", 1132 + content, 1133 + flags=re.IGNORECASE, 1134 + ) 1135 + content = re.sub( 1136 + r'<a(?:\s[^>]*?)?\s*href=["\']([^"\']*)["\'](?:\s[^>]*)?>([^<]*)</a>', 1137 + r"[\2](\1)", 1138 + content, 1139 + flags=re.IGNORECASE, 1140 + ) 923 1141 924 1142 # Convert block elements to spaces instead of newlines for compactness 925 - content = re.sub(r'<br\s*/?>', ' ', content, flags=re.IGNORECASE) 926 - content = re.sub(r'</p>\s*<p>', '. ', content, flags=re.IGNORECASE) 927 - content = re.sub(r'</?(?:p|div)(?:\s[^>]*)?>', ' ', content, flags=re.IGNORECASE) 1143 + content = re.sub(r"<br\s*/?>", " ", content, flags=re.IGNORECASE) 1144 + content = re.sub(r"</p>\s*<p>", ". ", content, flags=re.IGNORECASE) 1145 + content = re.sub( 1146 + r"</?(?:p|div)(?:\s[^>]*)?>", " ", content, flags=re.IGNORECASE 1147 + ) 928 1148 929 1149 # Remove remaining HTML tags 930 - content = re.sub(r'<[^>]+>', '', content) 1150 + content = re.sub(r"<[^>]+>", "", content) 931 1151 932 1152 # Clean up whitespace and make compact 933 - content = re.sub(r'\s+', ' ', content) # Multiple whitespace becomes single space 934 - content = re.sub(r'\.\.+', '.', content) # Multiple periods become single period 1153 + content = re.sub( 1154 + r"\s+", " ", content 1155 + ) # Multiple whitespace becomes single space 1156 + content = re.sub( 1157 + r"\.\.+", ".", content 1158 + ) # Multiple periods become single period 935 1159 return content.strip() 936 1160 937 1161 except Exception as e: 938 1162 self.logger.error(f"Error processing HTML content: {e}") 939 1163 # Last resort: just strip HTML tags 940 1164 import re 941 - return re.sub(r'<[^>]+>', '', html_content).strip() 1165 + 1166 + return re.sub(r"<[^>]+>", "", html_content).strip() 942 1167 943 1168 def _get_schedule_info(self) -> str: 944 1169 """Get schedule information string.""" ··· 946 1171 947 1172 if self.last_sync_time: 948 1173 import datetime 1174 + 949 1175 last_sync = datetime.datetime.fromtimestamp(self.last_sync_time) 950 1176 next_sync = last_sync + datetime.timedelta(seconds=self.sync_interval) 951 1177 now = datetime.datetime.now() ··· 964 1190 else: 965 1191 time_str = f"{seconds}s" 966 1192 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 - ]) 1193 + lines.extend( 1194 + [ 1195 + f"🕐 **Last Sync:** {last_sync.strftime('%H:%M:%S')}", 1196 + f"⏰ **Next Sync:** {next_sync.strftime('%H:%M:%S')} (in {time_str})", 1197 + ] 1198 + ) 971 1199 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 - ]) 1200 + lines.extend( 1201 + [ 1202 + f"🕐 **Last Sync:** {last_sync.strftime('%H:%M:%S')}", 1203 + f"⏰ **Next Sync:** Due now (running every {self.sync_interval}s)", 1204 + ] 1205 + ) 976 1206 else: 977 1207 lines.append("🕐 **Last Sync:** Never (bot starting up)") 978 1208 979 1209 # Add sync frequency info 980 1210 if self.sync_interval >= 3600: 981 - frequency_str = f"{self.sync_interval // 3600}h {(self.sync_interval % 3600) // 60}m" 1211 + frequency_str = ( 1212 + f"{self.sync_interval // 3600}h {(self.sync_interval % 3600) // 60}m" 1213 + ) 982 1214 elif self.sync_interval >= 60: 983 1215 frequency_str = f"{self.sync_interval // 60}m {self.sync_interval % 60}s" 984 1216 else: ··· 988 1220 989 1221 return "\n".join(lines) 990 1222 991 - def _send_config_change_notification(self, bot_handler: BotHandler, changer: str, setting: str, old_value: Optional[str], new_value: str) -> None: 1223 + def _send_config_change_notification( 1224 + self, 1225 + bot_handler: BotHandler, 1226 + changer: str, 1227 + setting: str, 1228 + old_value: Optional[str], 1229 + new_value: str, 1230 + ) -> None: 992 1231 """Send configuration change notification if enabled.""" 993 1232 if not self.config_change_notifications or self.debug_user: 994 1233 return ··· 999 1238 1000 1239 try: 1001 1240 old_display = old_value if old_value else "(not set)" 1002 - notification_msg = f"⚙️ **{changer}** changed {setting}: `{old_display}` → `{new_value}`" 1241 + notification_msg = ( 1242 + f"⚙️ **{changer}** changed {setting}: `{old_display}` → `{new_value}`" 1243 + ) 1003 1244 1004 - bot_handler.send_message({ 1005 - "type": "stream", 1006 - "to": self.stream_name, 1007 - "subject": self.topic_name, 1008 - "content": notification_msg 1009 - }) 1245 + bot_handler.send_message( 1246 + { 1247 + "type": "stream", 1248 + "to": self.stream_name, 1249 + "subject": self.topic_name, 1250 + "content": notification_msg, 1251 + } 1252 + ) 1010 1253 except Exception as e: 1011 1254 self.logger.error(f"Failed to send config change notification: {e}") 1012 1255 1013 1256 1014 1257 handler_class = ThicketBotHandler 1015 -
+12 -1
src/thicket/cli/commands/__init__.py
··· 14 14 zulip, 15 15 ) 16 16 17 - __all__ = ["add", "bot", "duplicates", "info_cmd", "init", "list_cmd", "search", "sync", "upload", "zulip"] 17 + __all__ = [ 18 + "add", 19 + "bot", 20 + "duplicates", 21 + "info_cmd", 22 + "init", 23 + "list_cmd", 24 + "search", 25 + "sync", 26 + "upload", 27 + "zulip", 28 + ]
+29 -13
src/thicket/cli/commands/bot.py
··· 40 40 ), 41 41 ) -> None: 42 42 """Manage the Thicket Zulip bot. 43 - 43 + 44 44 Actions: 45 45 - run: Start the Zulip bot 46 46 - test: Test bot functionality ··· 59 59 raise typer.Exit(1) 60 60 61 61 62 - def _run_bot(config_file: Path, thicket_config: Path, daemon: bool, debug_user: str = None) -> None: 62 + def _run_bot( 63 + config_file: Path, thicket_config: Path, daemon: bool, debug_user: str = None 64 + ) -> None: 63 65 """Run the Zulip bot.""" 64 66 if not config_file.exists(): 65 67 print_error(f"Configuration file not found: {config_file}") 66 - print_info(f"Copy bot-config/zuliprc.template to {config_file} and configure it") 68 + print_info( 69 + f"Copy bot-config/zuliprc.template to {config_file} and configure it" 70 + ) 67 71 print_info("See bot-config/README.md for setup instructions") 68 72 raise typer.Exit(1) 69 73 ··· 79 83 print_info(f"Using thicket config: {thicket_config}") 80 84 81 85 if debug_user: 82 - print_info(f"🐛 DEBUG MODE: Will send DMs to thicket user '{debug_user}' instead of posting to streams") 86 + print_info( 87 + f"🐛 DEBUG MODE: Will send DMs to thicket user '{debug_user}' instead of posting to streams" 88 + ) 83 89 84 90 if daemon: 85 91 print_info("Running in daemon mode...") ··· 90 96 try: 91 97 # Build the command 92 98 cmd = [ 93 - sys.executable, "-m", "zulip_bots.run", 99 + sys.executable, 100 + "-m", 101 + "zulip_bots.run", 94 102 "src/thicket/bots/thicket_bot.py", 95 - "--config-file", str(config_file) 103 + "--config-file", 104 + str(config_file), 96 105 ] 97 106 98 107 # Add environment variables for bot configuration 99 108 import os 109 + 100 110 env = os.environ.copy() 101 111 102 112 # Always pass thicket config path ··· 117 127 stdout=subprocess.DEVNULL, 118 128 stderr=subprocess.DEVNULL, 119 129 start_new_session=True, 120 - env=env 130 + env=env, 121 131 ) 122 132 print_success(f"Bot started in background with PID {process.pid}") 123 133 else: ··· 126 136 127 137 except subprocess.CalledProcessError as e: 128 138 print_error(f"Failed to start bot: {e}") 129 - raise typer.Exit(1) 139 + raise typer.Exit(1) from e 130 140 except KeyboardInterrupt: 131 141 print_info("Bot stopped by user") 132 142 ··· 187 197 188 198 except Exception as e: 189 199 print_error(f"Bot test failed: {e}") 190 - raise typer.Exit(1) 200 + raise typer.Exit(1) from e 191 201 192 202 193 203 def _bot_status(config_file: Path) -> None: ··· 200 210 console.print(f"✓ Config file: {config_file}", style="green") 201 211 else: 202 212 console.print(f"✗ Config file not found: {config_file}", style="red") 203 - console.print(" Copy bot-config/zuliprc.template and configure it", style="yellow") 204 - console.print(" See bot-config/README.md for setup instructions", style="yellow") 213 + console.print( 214 + " Copy bot-config/zuliprc.template and configure it", style="yellow" 215 + ) 216 + console.print( 217 + " See bot-config/README.md for setup instructions", style="yellow" 218 + ) 205 219 206 220 # Check dependencies 207 221 try: 208 222 import zulip_bots 209 - version = getattr(zulip_bots, '__version__', 'unknown') 223 + 224 + version = getattr(zulip_bots, "__version__", "unknown") 210 225 console.print(f"✓ zulip-bots version: {version}", style="green") 211 226 except ImportError: 212 227 console.print("✗ zulip-bots not installed", style="red") 213 228 214 229 try: 215 - from ...bots.thicket_bot import ThicketBotHandler 230 + from ...bots.thicket_bot import ThicketBotHandler # noqa: F401 231 + 216 232 console.print("✓ ThicketBotHandler available", style="green") 217 233 except ImportError as e: 218 234 console.print(f"✗ Bot handler not available: {e}", style="red")
+1 -1
src/thicket/cli/commands/info_cmd.py
··· 118 118 119 119 except Exception as e: 120 120 console.print(f"[red]Error displaying entry info: {e}[/red]") 121 - raise typer.Exit(1) 121 + raise typer.Exit(1) from e 122 122 123 123 124 124 def _display_entry_info(entry, username: str) -> None:
+40 -35
src/thicket/cli/commands/search.py
··· 121 121 # Check that we have required configuration 122 122 if not final_url: 123 123 console.print("[red]Error: Typesense URL is required[/red]") 124 - console.print("Either provide --typesense-url or create ~/.typesense/url file") 124 + console.print( 125 + "Either provide --typesense-url or create ~/.typesense/url file" 126 + ) 125 127 raise typer.Exit(1) 126 128 127 129 if not final_api_key: 128 130 console.print("[red]Error: Typesense API key is required[/red]") 129 - console.print("Either provide --api-key or create ~/.typesense/api_key file") 131 + console.print( 132 + "Either provide --api-key or create ~/.typesense/api_key file" 133 + ) 130 134 raise typer.Exit(1) 131 135 132 136 # Create Typesense configuration 133 137 typesense_config = TypesenseConfig.from_url( 134 - final_url, 135 - final_api_key, 136 - collection_name 138 + final_url, final_api_key, collection_name 137 139 ) 138 140 typesense_config.connection_timeout = timeout 139 141 ··· 147 149 148 150 # Prepare search parameters 149 151 search_params = { 150 - 'per_page': limit, 152 + "per_page": limit, 151 153 } 152 154 153 155 # Add user filter if specified 154 156 if user: 155 - search_params['filter_by'] = f'username:{user}' 157 + search_params["filter_by"] = f"username:{user}" 156 158 157 159 # Perform search 158 160 try: ··· 160 162 161 163 if raw: 162 164 import json 165 + 163 166 console.print(json.dumps(results, indent=2)) 164 167 return 165 168 ··· 178 181 179 182 def _display_search_results(results: dict, query: str) -> None: 180 183 """Display search results in a formatted table.""" 181 - hits = results.get('hits', []) 182 - found = results.get('found', 0) 183 - search_time = results.get('search_time_ms', 0) 184 + hits = results.get("hits", []) 185 + found = results.get("found", 0) 186 + search_time = results.get("search_time_ms", 0) 184 187 185 188 if not hits: 186 189 console.print("\n[yellow]No results found.[/yellow]") ··· 196 199 table.add_column("Summary", style="dim", width=50) 197 200 198 201 for hit in hits: 199 - doc = hit['document'] 202 + doc = hit["document"] 200 203 201 204 # Format score 202 205 score = f"{hit.get('text_match', 0):.2f}" 203 206 204 207 # Format user 205 - user_display = doc.get('user_display_name', doc.get('username', 'Unknown')) 208 + user_display = doc.get("user_display_name", doc.get("username", "Unknown")) 206 209 if len(user_display) > 12: 207 210 user_display = user_display[:9] + "..." 208 211 209 212 # Format title 210 - title = doc.get('title', 'Untitled') 213 + title = doc.get("title", "Untitled") 211 214 if len(title) > 40: 212 215 title = title[:37] + "..." 213 216 214 217 # Format date 215 - updated_timestamp = doc.get('updated', 0) 218 + updated_timestamp = doc.get("updated", 0) 216 219 if updated_timestamp: 217 220 from datetime import datetime 221 + 218 222 updated_date = datetime.fromtimestamp(updated_timestamp) 219 223 updated_str = updated_date.strftime("%Y-%m-%d") 220 224 else: 221 225 updated_str = "Unknown" 222 226 223 227 # Format summary 224 - summary = doc.get('summary') or doc.get('content', '') 228 + summary = doc.get("summary") or doc.get("content", "") 225 229 if summary: 226 230 # Remove HTML tags and truncate 227 231 import re 228 - summary = re.sub(r'<[^>]+>', '', summary) 232 + 233 + summary = re.sub(r"<[^>]+>", "", summary) 229 234 summary = summary.strip() 230 235 if len(summary) > 60: 231 236 summary = summary[:57] + "..." 232 237 else: 233 238 summary = "" 234 239 235 - table.add_row( 236 - score, 237 - user_display, 238 - title, 239 - updated_str, 240 - summary 241 - ) 240 + table.add_row(score, user_display, title, updated_str, summary) 242 241 243 242 console.print(table) 244 243 245 244 # Show additional info 246 245 console.print(f"\n[dim]Showing {len(hits)} of {found} results[/dim]") 247 246 if len(hits) < found: 248 - console.print(f"[dim]Use --limit to see more results (current limit: {len(hits)})[/dim]") 247 + console.print( 248 + f"[dim]Use --limit to see more results (current limit: {len(hits)})[/dim]" 249 + ) 249 250 250 251 251 252 def _display_compact_results(results: dict, query: str) -> None: 252 253 """Display search results in a compact format.""" 253 - hits = results.get('hits', []) 254 - found = results.get('found', 0) 254 + hits = results.get("hits", []) 255 + found = results.get("found", 0) 255 256 256 257 if not hits: 257 258 console.print("\n[yellow]No results found.[/yellow]") ··· 260 261 console.print(f"\n[green]Found {found} results[/green]\n") 261 262 262 263 for i, hit in enumerate(hits, 1): 263 - doc = hit['document'] 264 - score = hit.get('text_match', 0) 264 + doc = hit["document"] 265 + score = hit.get("text_match", 0) 265 266 266 267 # Header with score and user 267 - user = doc.get('user_display_name', doc.get('username', 'Unknown')) 268 - console.print(f"[green]{i:2d}.[/green] [cyan]{user}[/cyan] [dim](score: {score:.2f})[/dim]") 268 + user = doc.get("user_display_name", doc.get("username", "Unknown")) 269 + console.print( 270 + f"[green]{i:2d}.[/green] [cyan]{user}[/cyan] [dim](score: {score:.2f})[/dim]" 271 + ) 269 272 270 273 # Title 271 - title = doc.get('title', 'Untitled') 274 + title = doc.get("title", "Untitled") 272 275 console.print(f" [bold]{title}[/bold]") 273 276 274 277 # Date and link 275 - updated_timestamp = doc.get('updated', 0) 278 + updated_timestamp = doc.get("updated", 0) 276 279 if updated_timestamp: 277 280 from datetime import datetime 281 + 278 282 updated_date = datetime.fromtimestamp(updated_timestamp) 279 283 updated_str = updated_date.strftime("%Y-%m-%d %H:%M") 280 284 else: 281 285 updated_str = "Unknown date" 282 286 283 - link = doc.get('link', '') 287 + link = doc.get("link", "") 284 288 console.print(f" [blue]{updated_str}[/blue] - [link={link}]{link}[/link]") 285 289 286 290 # Summary 287 - summary = doc.get('summary') or doc.get('content', '') 291 + summary = doc.get("summary") or doc.get("content", "") 288 292 if summary: 289 293 import re 290 - summary = re.sub(r'<[^>]+>', '', summary) 294 + 295 + summary = re.sub(r"<[^>]+>", "", summary) 291 296 summary = summary.strip() 292 297 if len(summary) > 150: 293 298 summary = summary[:147] + "..."
+44 -20
src/thicket/cli/commands/upload.py
··· 42 42 return url, api_key 43 43 44 44 45 - def _save_typesense_config(url: Optional[str] = None, api_key: Optional[str] = None) -> None: 45 + def _save_typesense_config( 46 + url: Optional[str] = None, api_key: Optional[str] = None 47 + ) -> None: 46 48 """Save Typesense URL and API key to ~/.typesense directory.""" 47 49 typesense_dir = Path.home() / ".typesense" 48 50 typesense_dir.mkdir(exist_ok=True, mode=0o700) # Secure permissions ··· 139 141 # Check that we have required configuration 140 142 if not final_url: 141 143 console.print("[red]Error: Typesense URL is required[/red]") 142 - console.print("Either provide --typesense-url or create ~/.typesense/url file") 144 + console.print( 145 + "Either provide --typesense-url or create ~/.typesense/url file" 146 + ) 143 147 raise typer.Exit(1) 144 148 145 149 if not final_api_key: 146 150 console.print("[red]Error: Typesense API key is required[/red]") 147 - console.print("Either provide --api-key or create ~/.typesense/api_key file") 151 + console.print( 152 + "Either provide --api-key or create ~/.typesense/api_key file" 153 + ) 148 154 raise typer.Exit(1) 149 155 150 156 # Save configuration if provided via command line (for future use) ··· 183 189 184 190 # Create Typesense configuration 185 191 typesense_config = TypesenseConfig.from_url( 186 - final_url, 187 - final_api_key, 188 - collection_name 192 + final_url, final_api_key, collection_name 189 193 ) 190 194 typesense_config.connection_timeout = timeout 191 195 ··· 200 204 raise typer.Exit(1) from e 201 205 202 206 203 - def _dry_run_upload(git_store: GitStore, config: ThicketConfig, typesense_config: TypesenseConfig) -> None: 207 + def _dry_run_upload( 208 + git_store: GitStore, config: ThicketConfig, typesense_config: TypesenseConfig 209 + ) -> None: 204 210 """Perform a dry run showing what would be uploaded.""" 205 211 console.print("\n[bold]Dry run analysis:[/bold]") 206 212 ··· 216 222 217 223 entry_files = list(user_dir.glob("*.json")) 218 224 total_entries += len(entry_files) 219 - console.print(f" ✅ User {username}: {len(entry_files)} entries would be uploaded") 225 + console.print( 226 + f" ✅ User {username}: {len(entry_files)} entries would be uploaded" 227 + ) 220 228 except Exception as e: 221 229 console.print(f" ❌ User {username}: Error loading entries - {e}") 222 230 ··· 224 232 console.print(f" • Total users: {len(index.users)}") 225 233 console.print(f" • Total entries to upload: {total_entries}") 226 234 console.print(f" • Target collection: {typesense_config.collection_name}") 227 - console.print(f" • Typesense server: {typesense_config.protocol}://{typesense_config.host}:{typesense_config.port}") 235 + console.print( 236 + f" • Typesense server: {typesense_config.protocol}://{typesense_config.host}:{typesense_config.port}" 237 + ) 228 238 229 239 if total_entries > 0: 230 240 console.print("\n[green]Ready to upload! Remove --dry-run to proceed.[/green]") ··· 232 242 console.print("\n[yellow]No entries found to upload.[/yellow]") 233 243 234 244 235 - def _perform_upload(git_store: GitStore, config: ThicketConfig, typesense_config: TypesenseConfig) -> None: 245 + def _perform_upload( 246 + git_store: GitStore, config: ThicketConfig, typesense_config: TypesenseConfig 247 + ) -> None: 236 248 """Perform the actual upload to Typesense.""" 237 249 with Progress( 238 250 SpinnerColumn(), 239 251 TextColumn("[progress.description]{task.description}"), 240 252 console=console, 241 253 ) as progress: 242 - 243 254 # Test connection 244 255 progress.add_task("Testing Typesense connection...", total=None) 245 256 ··· 260 271 TextColumn("[progress.description]{task.description}"), 261 272 console=console, 262 273 ) as upload_progress: 263 - 264 274 upload_progress.add_task("Uploading entries to Typesense...", total=None) 265 275 266 276 try: ··· 273 283 # Batch import results 274 284 success_count = sum(1 for r in result if r.get("success")) 275 285 total_count = len(result) 276 - console.print(f"[green]✅ Upload completed: {success_count}/{total_count} documents uploaded successfully[/green]") 286 + console.print( 287 + f"[green]✅ Upload completed: {success_count}/{total_count} documents uploaded successfully[/green]" 288 + ) 277 289 278 290 # Show any errors 279 291 errors = [r for r in result if not r.get("success")] 280 292 if errors: 281 - console.print(f"[yellow]⚠️ {len(errors)} documents had errors[/yellow]") 282 - for i, error in enumerate(errors[:5]): # Show first 5 errors 283 - console.print(f" Error {i+1}: {error}") 293 + console.print( 294 + f"[yellow]⚠️ {len(errors)} documents had errors[/yellow]" 295 + ) 296 + for i, error in enumerate( 297 + errors[:5] 298 + ): # Show first 5 errors 299 + console.print(f" Error {i + 1}: {error}") 284 300 if len(errors) > 5: 285 - console.print(f" ... and {len(errors) - 5} more errors") 301 + console.print( 302 + f" ... and {len(errors) - 5} more errors" 303 + ) 286 304 else: 287 305 console.print("[green]✅ Upload completed successfully[/green]") 288 306 else: 289 - console.print("[yellow]⚠️ Upload completed but no result data available[/yellow]") 307 + console.print( 308 + "[yellow]⚠️ Upload completed but no result data available[/yellow]" 309 + ) 290 310 291 311 console.print("\n[bold]Collection information:[/bold]") 292 - console.print(f" • Server: {typesense_config.protocol}://{typesense_config.host}:{typesense_config.port}") 312 + console.print( 313 + f" • Server: {typesense_config.protocol}://{typesense_config.host}:{typesense_config.port}" 314 + ) 293 315 console.print(f" • Collection: {typesense_config.collection_name}") 294 - console.print("\n[dim]You can now search your entries using the Typesense API or dashboard.[/dim]") 316 + console.print( 317 + "\n[dim]You can now search your entries using the Typesense API or dashboard.[/dim]" 318 + ) 295 319 296 320 except Exception as e: 297 321 upload_progress.stop()
+26 -16
src/thicket/cli/commands/zulip.py
··· 17 17 @app.command() 18 18 def zulip_add( 19 19 username: str = typer.Argument(..., help="Username to associate with Zulip"), 20 - server: str = typer.Argument(..., help="Zulip server (e.g., yourorg.zulipchat.com)"), 20 + server: str = typer.Argument( 21 + ..., help="Zulip server (e.g., yourorg.zulipchat.com)" 22 + ), 21 23 user_id: str = typer.Argument(..., help="Zulip user ID or email for @mentions"), 22 24 config_file: Path = typer.Option( 23 25 Path("thicket.yaml"), ··· 27 29 ), 28 30 ) -> None: 29 31 """Add a Zulip association for a user. 30 - 32 + 31 33 This associates a thicket user with their Zulip identity, enabling 32 34 @mentions when the bot posts their articles. 33 - 35 + 34 36 Example: 35 37 thicket zulip-add alice myorg.zulipchat.com alice@example.com 36 38 """ ··· 53 55 54 56 except Exception as e: 55 57 print_error(f"Failed to add Zulip association: {e}") 56 - raise typer.Exit(1) 58 + raise typer.Exit(1) from e 57 59 58 60 59 61 @app.command() ··· 69 71 ), 70 72 ) -> None: 71 73 """Remove a Zulip association from a user. 72 - 74 + 73 75 Example: 74 76 thicket zulip-remove alice myorg.zulipchat.com alice@example.com 75 77 """ ··· 85 87 86 88 # Remove association 87 89 if git_store.remove_zulip_association(username, server, user_id): 88 - print_success(f"Removed Zulip association for {username}: {user_id}@{server}") 90 + print_success( 91 + f"Removed Zulip association for {username}: {user_id}@{server}" 92 + ) 89 93 git_store.commit_changes(f"Remove Zulip association for {username}") 90 94 else: 91 95 print_error(f"Association not found for {username}: {user_id}@{server}") ··· 93 97 94 98 except Exception as e: 95 99 print_error(f"Failed to remove Zulip association: {e}") 96 - raise typer.Exit(1) 100 + raise typer.Exit(1) from e 97 101 98 102 99 103 @app.command() 100 104 def zulip_list( 101 - username: Optional[str] = typer.Argument(None, help="Username to list associations for"), 105 + username: Optional[str] = typer.Argument( 106 + None, help="Username to list associations for" 107 + ), 102 108 config_file: Path = typer.Option( 103 109 Path("thicket.yaml"), 104 110 "--config", ··· 107 113 ), 108 114 ) -> None: 109 115 """List Zulip associations for users. 110 - 116 + 111 117 If no username is provided, lists associations for all users. 112 - 118 + 113 119 Examples: 114 120 thicket zulip-list # List all associations 115 121 thicket zulip-list alice # List associations for alice ··· 155 161 156 162 except Exception as e: 157 163 print_error(f"Failed to list Zulip associations: {e}") 158 - raise typer.Exit(1) 164 + raise typer.Exit(1) from e 159 165 160 166 161 167 @app.command() ··· 174 180 ), 175 181 ) -> None: 176 182 """Import Zulip associations from a CSV file. 177 - 183 + 178 184 CSV format (no header): 179 185 username,server,user_id 180 186 alice,myorg.zulipchat.com,alice@example.com 181 187 bob,myorg.zulipchat.com,bob.smith 182 - 188 + 183 189 Example: 184 190 thicket zulip-import associations.csv 185 191 """ ··· 225 231 for a in user.zulip_associations 226 232 ) 227 233 if exists: 228 - print_info(f"Would skip existing: {username} -> {user_id}@{server}") 234 + print_info( 235 + f"Would skip existing: {username} -> {user_id}@{server}" 236 + ) 229 237 skipped += 1 230 238 else: 231 239 print_info(f"Would add: {username} -> {user_id}@{server}") ··· 236 244 print_success(f"Added: {username} -> {user_id}@{server}") 237 245 added += 1 238 246 else: 239 - print_info(f"Skipped existing: {username} -> {user_id}@{server}") 247 + print_info( 248 + f"Skipped existing: {username} -> {user_id}@{server}" 249 + ) 240 250 skipped += 1 241 251 242 252 # Summary ··· 255 265 256 266 except Exception as e: 257 267 print_error(f"Failed to import Zulip associations: {e}") 258 - raise typer.Exit(1) 268 + raise typer.Exit(1) from e
+1 -1
src/thicket/cli/main.py
··· 47 47 48 48 49 49 # Import commands to register them 50 - from .commands import ( # noqa: F401 50 + from .commands import ( # noqa: F401, E402 51 51 add, 52 52 duplicates, 53 53 info_cmd,
+3 -1
src/thicket/core/git_store.py
··· 167 167 168 168 return result 169 169 170 - def remove_zulip_association(self, username: str, server: str, user_id: str) -> bool: 170 + def remove_zulip_association( 171 + self, username: str, server: str, user_id: str 172 + ) -> bool: 171 173 """Remove a Zulip association from a user.""" 172 174 index = self._load_index() 173 175 user = index.get_user(username)
+114 -58
src/thicket/core/typesense_client.py
··· 30 30 collection_name: str = "thicket_entries" 31 31 32 32 @classmethod 33 - def from_url(cls, url: str, api_key: str, collection_name: str = "thicket_entries") -> "TypesenseConfig": 33 + def from_url( 34 + cls, url: str, api_key: str, collection_name: str = "thicket_entries" 35 + ) -> "TypesenseConfig": 34 36 """Create config from Typesense URL.""" 35 37 parsed = urlparse(url) 36 38 return cls( ··· 207 209 def __init__(self, config: TypesenseConfig): 208 210 """Initialize Typesense client.""" 209 211 self.config = config 210 - self.client = typesense.Client({ 211 - 'nodes': [{ 212 - 'host': config.host, 213 - 'port': config.port, 214 - 'protocol': config.protocol, 215 - }], 216 - 'api_key': config.api_key, 217 - 'connection_timeout_seconds': config.connection_timeout, 218 - }) 212 + self.client = typesense.Client( 213 + { 214 + "nodes": [ 215 + { 216 + "host": config.host, 217 + "port": config.port, 218 + "protocol": config.protocol, 219 + } 220 + ], 221 + "api_key": config.api_key, 222 + "connection_timeout_seconds": config.connection_timeout, 223 + } 224 + ) 219 225 220 226 def get_collection_schema(self) -> dict[str, Any]: 221 227 """Get the Typesense collection schema for thicket entries.""" 222 228 return { 223 - 'name': self.config.collection_name, 224 - 'fields': [ 229 + "name": self.config.collection_name, 230 + "fields": [ 225 231 # Primary identifiers 226 - {'name': 'id', 'type': 'string', 'facet': False}, 227 - {'name': 'original_id', 'type': 'string', 'facet': False}, 228 - 232 + {"name": "id", "type": "string", "facet": False}, 233 + {"name": "original_id", "type": "string", "facet": False}, 229 234 # Content fields - optimized for search 230 - {'name': 'title', 'type': 'string', 'facet': False}, 231 - {'name': 'summary', 'type': 'string', 'optional': True, 'facet': False}, 232 - {'name': 'content', 'type': 'string', 'optional': True, 'facet': False}, 233 - {'name': 'content_type', 'type': 'string', 'facet': True}, 234 - 235 + {"name": "title", "type": "string", "facet": False}, 236 + {"name": "summary", "type": "string", "optional": True, "facet": False}, 237 + {"name": "content", "type": "string", "optional": True, "facet": False}, 238 + {"name": "content_type", "type": "string", "facet": True}, 235 239 # Searchable combined fields for embeddings/semantic search 236 - {'name': 'searchable_content', 'type': 'string', 'facet': False}, 237 - {'name': 'searchable_metadata', 'type': 'string', 'facet': False}, 238 - 240 + {"name": "searchable_content", "type": "string", "facet": False}, 241 + {"name": "searchable_metadata", "type": "string", "facet": False}, 239 242 # Temporal fields 240 - {'name': 'updated', 'type': 'int64', 'facet': False, 'sort': True}, 241 - {'name': 'published', 'type': 'int64', 'optional': True, 'facet': False, 'sort': True}, 242 - 243 + {"name": "updated", "type": "int64", "facet": False, "sort": True}, 244 + { 245 + "name": "published", 246 + "type": "int64", 247 + "optional": True, 248 + "facet": False, 249 + "sort": True, 250 + }, 243 251 # Link and source 244 - {'name': 'link', 'type': 'string', 'facet': False}, 245 - {'name': 'source', 'type': 'string', 'optional': True, 'facet': False}, 246 - 252 + {"name": "link", "type": "string", "facet": False}, 253 + {"name": "source", "type": "string", "optional": True, "facet": False}, 247 254 # Categories and classification 248 - {'name': 'categories', 'type': 'string[]', 'facet': True, 'optional': True}, 249 - {'name': 'rights', 'type': 'string', 'optional': True, 'facet': False}, 250 - 255 + { 256 + "name": "categories", 257 + "type": "string[]", 258 + "facet": True, 259 + "optional": True, 260 + }, 261 + {"name": "rights", "type": "string", "optional": True, "facet": False}, 251 262 # User/feed metadata - facetable for filtering 252 - {'name': 'username', 'type': 'string', 'facet': True}, 253 - {'name': 'user_display_name', 'type': 'string', 'optional': True, 'facet': True}, 254 - {'name': 'user_email', 'type': 'string', 'optional': True, 'facet': False}, 255 - {'name': 'user_homepage', 'type': 'string', 'optional': True, 'facet': False}, 256 - {'name': 'user_icon', 'type': 'string', 'optional': True, 'facet': False}, 257 - 263 + {"name": "username", "type": "string", "facet": True}, 264 + { 265 + "name": "user_display_name", 266 + "type": "string", 267 + "optional": True, 268 + "facet": True, 269 + }, 270 + { 271 + "name": "user_email", 272 + "type": "string", 273 + "optional": True, 274 + "facet": False, 275 + }, 276 + { 277 + "name": "user_homepage", 278 + "type": "string", 279 + "optional": True, 280 + "facet": False, 281 + }, 282 + { 283 + "name": "user_icon", 284 + "type": "string", 285 + "optional": True, 286 + "facet": False, 287 + }, 258 288 # Author information from entries 259 - {'name': 'author_name', 'type': 'string', 'optional': True, 'facet': True}, 260 - {'name': 'author_email', 'type': 'string', 'optional': True, 'facet': False}, 261 - {'name': 'author_uri', 'type': 'string', 'optional': True, 'facet': False}, 289 + { 290 + "name": "author_name", 291 + "type": "string", 292 + "optional": True, 293 + "facet": True, 294 + }, 295 + { 296 + "name": "author_email", 297 + "type": "string", 298 + "optional": True, 299 + "facet": False, 300 + }, 301 + { 302 + "name": "author_uri", 303 + "type": "string", 304 + "optional": True, 305 + "facet": False, 306 + }, 262 307 ], 263 - 'default_sorting_field': 'updated', 308 + "default_sorting_field": "updated", 264 309 } 265 310 266 311 def create_collection(self) -> dict[str, Any]: ··· 269 314 # Try to delete existing collection first 270 315 try: 271 316 self.client.collections[self.config.collection_name].delete() 272 - logger.info(f"Deleted existing collection: {self.config.collection_name}") 317 + logger.info( 318 + f"Deleted existing collection: {self.config.collection_name}" 319 + ) 273 320 except typesense.exceptions.ObjectNotFound: 274 - logger.info(f"Collection {self.config.collection_name} does not exist, creating new one") 321 + logger.info( 322 + f"Collection {self.config.collection_name} does not exist, creating new one" 323 + ) 275 324 276 325 # Create new collection 277 326 schema = self.get_collection_schema() ··· 290 339 document_dicts = [doc.model_dump() for doc in documents] 291 340 292 341 # Use import endpoint for batch indexing 293 - result = self.client.collections[self.config.collection_name].documents.import_( 342 + result = self.client.collections[ 343 + self.config.collection_name 344 + ].documents.import_( 294 345 document_dicts, 295 - {'action': 'upsert'} # Update if exists, insert if not 346 + {"action": "upsert"}, # Update if exists, insert if not 296 347 ) 297 348 298 349 logger.info(f"Indexed {len(documents)} documents") ··· 302 353 logger.error(f"Failed to index documents: {e}") 303 354 raise 304 355 305 - def upload_from_git_store(self, git_store: GitStore, config: ThicketConfig) -> dict[str, Any]: 356 + def upload_from_git_store( 357 + self, git_store: GitStore, config: ThicketConfig 358 + ) -> dict[str, Any]: 306 359 """Upload all entries from the Git store to Typesense.""" 307 360 logger.info("Starting Typesense upload from Git store") 308 361 ··· 319 372 try: 320 373 user_dir = git_store.repo_path / user_metadata.directory 321 374 if not user_dir.exists(): 322 - logger.warning(f"Directory not found for user {username}: {user_dir}") 375 + logger.warning( 376 + f"Directory not found for user {username}: {user_dir}" 377 + ) 323 378 continue 324 379 325 380 entry_files = list(user_dir.glob("*.json")) ··· 338 393 ) 339 394 documents.append(doc) 340 395 except Exception as e: 341 - logger.error(f"Failed to convert entry {entry_file} to document: {e}") 396 + logger.error( 397 + f"Failed to convert entry {entry_file} to document: {e}" 398 + ) 342 399 343 400 except Exception as e: 344 401 logger.error(f"Failed to load entries for user {username}: {e}") ··· 353 410 return {} 354 411 355 412 def search( 356 - self, 357 - query: str, 358 - search_parameters: Optional[dict[str, Any]] = None 413 + self, query: str, search_parameters: Optional[dict[str, Any]] = None 359 414 ) -> dict[str, Any]: 360 415 """Search the collection.""" 361 416 default_params = { 362 - 'q': query, 363 - 'query_by': 'title,searchable_content,searchable_metadata', 364 - 'sort_by': 'updated:desc', 365 - 'per_page': 20, 417 + "q": query, 418 + "query_by": "title,searchable_content,searchable_metadata", 419 + "sort_by": "updated:desc", 420 + "per_page": 20, 366 421 } 367 422 368 423 if search_parameters: 369 424 default_params.update(search_parameters) 370 425 371 - return self.client.collections[self.config.collection_name].documents.search(default_params) 372 - 426 + return self.client.collections[self.config.collection_name].documents.search( 427 + default_params 428 + )
+3 -1
src/thicket/models/user.py
··· 31 31 homepage: Optional[str] = None 32 32 icon: Optional[str] = None 33 33 feeds: list[str] = [] 34 - zulip_associations: list[ZulipAssociation] = Field(default_factory=list) # Zulip server/user pairs 34 + zulip_associations: list[ZulipAssociation] = Field( 35 + default_factory=list 36 + ) # Zulip server/user pairs 35 37 directory: str # Directory name in Git store 36 38 created: datetime 37 39 last_updated: datetime
+6 -6
tests/test_bot.py
··· 1 1 """Tests for the Thicket Zulip bot.""" 2 2 3 - 4 3 import pytest 5 4 6 5 from thicket.bots.test_bot import ( ··· 216 215 result = self.bot._check_initialization(message, self.handler) 217 216 assert result is False 218 217 assert len(self.handler.sent_messages) == 1 219 - assert "Debug mode validation failed" in self.handler.sent_messages[0]["content"] 218 + assert ( 219 + "Debug mode validation failed" in self.handler.sent_messages[0]["content"] 220 + ) 220 221 221 222 def test_debug_mode_dm_posting(self) -> None: 222 223 """Test that debug mode posts DMs instead of stream messages.""" 223 224 from unittest.mock import Mock 224 - 225 225 226 226 # Setup bot in debug mode 227 227 self.bot.debug_user = "testuser" ··· 235 235 self.handler.config_info = { 236 236 "full_name": "Thicket Bot", 237 237 "email": "thicket-bot@example.com", 238 - "site": "https://example.zulipchat.com" 238 + "site": "https://example.zulipchat.com", 239 239 } 240 240 241 241 # Mock git store user ··· 275 275 tester = BotTester() 276 276 277 277 # Configure stream 278 - responses = tester.send_command("config stream general") 278 + tester.send_command("config stream general") 279 279 tester.assert_response_contains("Stream set to") 280 280 281 281 # Configure topic 282 - responses = tester.send_command("config topic test") 282 + tester.send_command("config topic test") 283 283 tester.assert_response_contains("Topic set to") 284 284 285 285 def test_assert_response_contains(self) -> None:
+2 -1
tests/test_feed_parser.py
··· 100 100 html_with_attrs = '<a href="https://example.com" onclick="alert()">Link</a>' 101 101 sanitized = parser._sanitize_html(html_with_attrs) 102 102 assert 'href="https://example.com"' in sanitized 103 - assert 'onclick' not in sanitized 103 + assert "onclick" not in sanitized 104 104 105 105 def test_extract_feed_metadata(self): 106 106 """Test feed metadata extraction.""" ··· 108 108 109 109 # Test with feedparser parsed data 110 110 import feedparser 111 + 111 112 parsed = feedparser.parse("""<?xml version="1.0" encoding="utf-8"?> 112 113 <feed xmlns="http://www.w3.org/2005/Atom"> 113 114 <title>Test Feed</title>
+7 -2
tests/test_git_store.py
··· 191 191 duplicates = store.get_duplicates() 192 192 assert len(duplicates.duplicates) == 1 193 193 assert duplicates.is_duplicate("https://example.com/dup") 194 - assert duplicates.get_canonical("https://example.com/dup") == "https://example.com/canonical" 194 + assert ( 195 + duplicates.get_canonical("https://example.com/dup") 196 + == "https://example.com/canonical" 197 + ) 195 198 196 199 # Remove duplicate 197 200 result = store.remove_duplicate("https://example.com/dup") ··· 220 223 entry = AtomEntry( 221 224 id=f"https://example.com/entry/{title.lower().replace(' ', '-')}", 222 225 title=title, 223 - link=HttpUrl(f"https://example.com/entry/{title.lower().replace(' ', '-')}"), 226 + link=HttpUrl( 227 + f"https://example.com/entry/{title.lower().replace(' ', '-')}" 228 + ), 224 229 updated=datetime.now(), 225 230 summary=summary, 226 231 )
+16 -18
tests/test_models.py
··· 127 127 git_store=temp_dir / "git_store", 128 128 cache_dir=temp_dir / "cache", 129 129 users=[ 130 - UserConfig(username="testuser", feeds=["https://example.com/feed1.xml"]), 130 + UserConfig( 131 + username="testuser", feeds=["https://example.com/feed1.xml"] 132 + ), 131 133 ], 132 134 ) 133 135 134 - result = config.add_feed_to_user("testuser", HttpUrl("https://example.com/feed2.xml")) 136 + result = config.add_feed_to_user( 137 + "testuser", HttpUrl("https://example.com/feed2.xml") 138 + ) 135 139 assert result is True 136 140 137 141 user = config.find_user("testuser") ··· 139 143 assert HttpUrl("https://example.com/feed2.xml") in user.feeds 140 144 141 145 # Test adding to non-existent user 142 - result = config.add_feed_to_user("nonexistent", HttpUrl("https://example.com/feed.xml")) 146 + result = config.add_feed_to_user( 147 + "nonexistent", HttpUrl("https://example.com/feed.xml") 148 + ) 143 149 assert result is False 144 150 145 151 ··· 294 300 user_config = metadata.to_user_config("testuser", feed_url) 295 301 296 302 assert user_config.display_name == "Test Feed" # Falls back to title 297 - assert user_config.homepage == HttpUrl("https://example.com") # Falls back to link 303 + assert user_config.homepage == HttpUrl( 304 + "https://example.com" 305 + ) # Falls back to link 298 306 assert user_config.icon == HttpUrl("https://example.com/icon.png") 299 307 assert user_config.email is None 300 308 ··· 405 413 def test_valid_association(self): 406 414 """Test creating valid Zulip association.""" 407 415 assoc = ZulipAssociation( 408 - server="example.zulipchat.com", 409 - user_id="alice@example.com" 416 + server="example.zulipchat.com", user_id="alice@example.com" 410 417 ) 411 418 412 419 assert assoc.server == "example.zulipchat.com" ··· 414 421 415 422 def test_association_hash(self): 416 423 """Test that associations are hashable.""" 417 - assoc1 = ZulipAssociation( 418 - server="example.zulipchat.com", 419 - user_id="alice" 420 - ) 421 - assoc2 = ZulipAssociation( 422 - server="example.zulipchat.com", 423 - user_id="alice" 424 - ) 425 - assoc3 = ZulipAssociation( 426 - server="other.zulipchat.com", 427 - user_id="alice" 428 - ) 424 + assoc1 = ZulipAssociation(server="example.zulipchat.com", user_id="alice") 425 + assoc2 = ZulipAssociation(server="example.zulipchat.com", user_id="alice") 426 + assoc3 = ZulipAssociation(server="other.zulipchat.com", user_id="alice") 429 427 430 428 # Same associations should have same hash 431 429 assert hash(assoc1) == hash(assoc2)