Manage Atom feeds in a persistent git repository

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>

+4
.gitignore
···
.streamlit/secrets.toml
thicket.yaml
+
+
# Bot configuration files with secrets
bot-config/zuliprc
+
bot-config/*.key
+
bot-config/*.secret
+97
bot-config/README.md
···
+
# Thicket Bot Configuration
+
+
This directory contains configuration files for the Thicket Zulip bot.
+
+
## Setup Instructions
+
+
### 1. Zulip Bot Configuration
+
+
1. Copy `zuliprc.template` to `zuliprc`:
+
```bash
+
cp bot-config/zuliprc.template bot-config/zuliprc
+
```
+
+
2. Create a bot in your Zulip organization:
+
- Go to Settings > Your bots > Add a new bot
+
- Choose "Generic bot" type
+
- Give it a name like "Thicket" and username like "thicket"
+
- Copy the bot's email and API key
+
+
3. Edit `bot-config/zuliprc` with your bot's credentials:
+
```ini
+
[api]
+
email=thicket-bot@your-org.zulipchat.com
+
key=your-actual-api-key-here
+
site=https://your-org.zulipchat.com
+
```
+
+
### 2. Bot Behavior Configuration (Optional)
+
+
1. Copy `botrc.template` to `botrc` to customize bot behavior:
+
```bash
+
cp bot-config/botrc.template bot-config/botrc
+
```
+
+
2. Edit `bot-config/botrc` to customize:
+
- Sync intervals and batch sizes
+
- Default stream/topic settings
+
- Rate limiting parameters
+
- Notification preferences
+
+
**Note**: The bot will work with default settings if no `botrc` file exists.
+
+
## File Descriptions
+
+
### `zuliprc` (Required)
+
Contains Zulip API credentials for the bot. This file should **never** be committed to version control.
+
+
### `botrc` (Optional)
+
Contains bot behavior configuration and defaults. This file can be committed to version control as it contains no secrets.
+
+
### Template Files
+
- `zuliprc.template` - Template for Zulip credentials
+
- `botrc.template` - Template for bot behavior settings
+
+
## Running the Bot
+
+
Once configured, run the bot with:
+
+
```bash
+
# Run in foreground
+
thicket bot run
+
+
# Run in background (daemon mode)
+
thicket bot run --daemon
+
+
# Debug mode (sends DMs instead of stream posts)
+
thicket bot run --debug-user your-thicket-username
+
+
# Custom config paths
+
thicket bot run --config bot-config/zuliprc --botrc bot-config/botrc
+
```
+
+
## Bot Commands
+
+
Once running, interact with the bot in Zulip:
+
+
- `@thicket help` - Show available commands
+
- `@thicket status` - Show bot status and configuration
+
- `@thicket sync now` - Force immediate sync
+
- `@thicket schedule` - Show sync schedule
+
- `@thicket claim <username>` - Claim a thicket username
+
- `@thicket config <setting> <value>` - Change bot settings
+
+
## Security Notes
+
+
- **Never commit `zuliprc` with real credentials**
+
- Add `bot-config/zuliprc` to `.gitignore`
+
- The `botrc` file contains no secrets and can be safely committed
+
- Bot settings changed via chat are stored in Zulip's persistent storage
+
+
## Troubleshooting
+
+
- Check bot status: `thicket bot status`
+
- View bot logs when running in foreground mode
+
- Verify Zulip credentials are correct
+
- Ensure thicket.yaml configuration exists
+
- Test bot functionality: `thicket bot test`
+28
bot-config/botrc
···
+
[bot]
+
# Default RSS feed polling interval in seconds (minimum 60)
+
sync_interval = 300
+
+
# Maximum number of entries to post per sync cycle
+
max_entries_per_sync = 10
+
+
# Default stream and topic for posting (can be overridden via chat commands)
+
# Leave empty to require configuration via chat
+
default_stream =
+
default_topic =
+
+
# Rate limiting: seconds to wait between batches of posts
+
rate_limit_delay = 5
+
+
# Number of posts per batch before applying rate limit
+
posts_per_batch = 5
+
+
[catchup]
+
# Number of entries to post on first run (catchup mode)
+
catchup_entries = 5
+
+
[notifications]
+
# Whether to send notifications when bot configuration changes
+
config_change_notifications = true
+
+
# Whether to send notifications when users claim usernames
+
username_claim_notifications = true
+34
bot-config/botrc.template
···
+
[bot]
+
# Default RSS feed polling interval in seconds (minimum 60)
+
sync_interval = 300
+
+
# Maximum number of entries to post per sync cycle (1-50)
+
max_entries_per_sync = 10
+
+
# Default stream and topic for posting (can be overridden via chat commands)
+
# Leave empty to require configuration via chat
+
default_stream =
+
default_topic =
+
+
# Rate limiting: seconds to wait between batches of posts
+
rate_limit_delay = 5
+
+
# Number of posts per batch before applying rate limit
+
posts_per_batch = 5
+
+
[catchup]
+
# Number of entries to post on first run (catchup mode)
+
catchup_entries = 5
+
+
[notifications]
+
# Whether to send notifications when bot configuration changes
+
config_change_notifications = true
+
+
# Whether to send notifications when users claim usernames
+
username_claim_notifications = true
+
+
# Instructions:
+
# 1. Copy this file to botrc (without .template extension) to customize bot behavior
+
# 2. The bot will use these defaults if no botrc file is found
+
# 3. All settings can be overridden via chat commands (e.g., @mention config interval 600)
+
# 4. Settings changed via chat are persisted in Zulip storage and take precedence
+16
bot-config/zuliprc.template
···
+
[api]
+
# Your bot's email address (create this in Zulip Settings > Bots)
+
email=your-bot@your-organization.zulipchat.com
+
+
# Your bot's API key (found in Zulip Settings > Bots)
+
key=YOUR_BOT_API_KEY_HERE
+
+
# Your Zulip server URL
+
site=https://your-organization.zulipchat.com
+
+
# Instructions:
+
# 1. Copy this file to zuliprc (without .template extension)
+
# 2. Replace the placeholder values with your actual bot credentials
+
# 3. Create a bot in your Zulip organization at Settings > Bots
+
# 4. Use the bot's email and API key from the Zulip interface
+
# 5. Never commit the actual zuliprc file with real credentials to version control
+1 -1
src/thicket/bots/__init__.py
···
from .thicket_bot import ThicketBotHandler
-
__all__ = ["ThicketBotHandler"]
+
__all__ = ["ThicketBotHandler"]
+38 -38
src/thicket/bots/test_bot.py
···
import json
from pathlib import Path
from typing import Any, Dict, Optional
-
from unittest.mock import Mock
-
from ..models import AtomEntry, ThicketConfig
+
from ..models import AtomEntry
from .thicket_bot import ThicketBotHandler
class MockBotHandler:
"""Mock BotHandler for testing the Thicket bot."""
-
+
def __init__(self) -> None:
"""Initialize mock bot handler."""
self.storage_data: Dict[str, str] = {}
···
"full_name": "Thicket Bot",
"email": "thicket-bot@example.com"
}
-
+
def get_config_info(self) -> Dict[str, str]:
"""Return bot configuration info."""
return self.config_info
-
+
def send_reply(self, message: Dict[str, Any], content: str) -> None:
"""Mock sending a reply."""
reply = {
···
"original_message": message
}
self.sent_messages.append(reply)
-
+
def send_message(self, message: Dict[str, Any]) -> None:
"""Mock sending a message."""
self.sent_messages.append(message)
-
+
@property
def storage(self) -> 'MockStorage':
"""Return mock storage."""
···
class MockStorage:
"""Mock storage for bot state."""
-
+
def __init__(self, storage_data: Dict[str, str]) -> None:
"""Initialize with storage data."""
self.storage_data = storage_data
-
+
def __enter__(self) -> 'MockStorage':
"""Context manager entry."""
return self
-
+
def __exit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None:
"""Context manager exit."""
pass
-
+
def get(self, key: str) -> Optional[str]:
"""Get value from storage."""
return self.storage_data.get(key)
-
+
def put(self, key: str, value: str) -> None:
"""Put value in storage."""
self.storage_data[key] = value
-
+
def contains(self, key: str) -> bool:
"""Check if key exists in storage."""
return key in self.storage_data
def create_test_message(
-
content: str,
+
content: str,
sender: str = "Test User",
sender_id: int = 12345,
message_type: str = "stream"
···
) -> AtomEntry:
"""Create a test AtomEntry for testing."""
from datetime import datetime
+
from pydantic import HttpUrl
-
+
return AtomEntry(
id=entry_id,
title=title,
···
class BotTester:
"""Helper class for testing bot functionality."""
-
+
def __init__(self, config_path: Optional[Path] = None) -> None:
"""Initialize bot tester."""
self.bot = ThicketBotHandler()
self.handler = MockBotHandler()
-
+
if config_path:
# Configure bot with test config
self.configure_bot(config_path, "test-stream", "test-topic")
-
+
def configure_bot(
-
self,
-
config_path: Path,
-
stream: str = "test-stream",
+
self,
+
config_path: Path,
+
stream: str = "test-stream",
topic: str = "test-topic"
) -> None:
"""Configure the bot for testing."""
# Set bot configuration
config_data = {
"stream_name": stream,
-
"topic_name": topic,
+
"topic_name": topic,
"sync_interval": 300,
"max_entries_per_sync": 10,
"config_path": str(config_path)
}
-
+
self.handler.storage_data["bot_config"] = json.dumps(config_data)
-
+
# Initialize bot
self.bot._load_bot_config(self.handler)
-
+
def send_command(self, command: str, sender: str = "Test User") -> list[Dict[str, Any]]:
"""Send a command to the bot and return responses."""
message = create_test_message(f"@thicket {command}", sender)
-
+
# Clear previous messages
self.handler.sent_messages.clear()
-
+
# Send command
self.bot.handle_message(message, self.handler)
-
+
return self.handler.sent_messages.copy()
-
+
def get_last_response_content(self) -> Optional[str]:
"""Get the content of the last bot response."""
if self.handler.sent_messages:
return self.handler.sent_messages[-1].get("content")
return None
-
+
def get_last_message(self) -> Optional[Dict[str, Any]]:
"""Get the last sent message."""
if self.handler.sent_messages:
return self.handler.sent_messages[-1]
return None
-
+
def assert_response_contains(self, text: str) -> None:
"""Assert that the last response contains specific text."""
content = self.get_last_response_content()
···
if __name__ == "__main__":
# Create a test config file
test_config = Path("/tmp/test_thicket.yaml")
-
+
# Create bot tester
tester = BotTester()
-
+
# Test help command
responses = tester.send_command("help")
print(f"Help response: {tester.get_last_response_content()}")
-
+
# Test status command
responses = tester.send_command("status")
print(f"Status response: {tester.get_last_response_content()}")
-
+
# Test configuration
responses = tester.send_command("config stream general")
tester.assert_response_contains("Stream set to")
-
+
responses = tester.send_command("config topic 'Feed Updates'")
tester.assert_response_contains("Topic set to")
-
-
print("All tests passed!")
+
+
print("All tests passed!")
+398 -137
src/thicket/bots/thicket_bot.py
···
import logging
import os
import time
-
from datetime import datetime
from pathlib import Path
-
from typing import Any, Dict, List, Optional, Set, Tuple
+
from typing import Any, Optional
from zulip_bots.lib import BotHandler
# Handle imports for both direct execution and package import
try:
+
from ..cli.commands.sync import sync_feed
from ..core.git_store import GitStore
from ..models import AtomEntry, ThicketConfig
-
from ..cli.commands.sync import sync_feed
except ImportError:
# When run directly by zulip-bots, add the package to path
import sys
src_dir = Path(__file__).parent.parent.parent
if str(src_dir) not in sys.path:
sys.path.insert(0, str(src_dir))
-
+
+
from thicket.cli.commands.sync import sync_feed
from thicket.core.git_store import GitStore
from thicket.models import AtomEntry, ThicketConfig
-
from thicket.cli.commands.sync import sync_feed
class ThicketBotHandler:
···
self.logger = logging.getLogger(__name__)
self.git_store: Optional[GitStore] = None
self.config: Optional[ThicketConfig] = None
-
self.posted_entries: Set[str] = set()
-
+
self.posted_entries: set[str] = set()
+
# Bot configuration from storage
self.stream_name: Optional[str] = None
self.topic_name: Optional[str] = None
self.sync_interval: int = 300 # 5 minutes default
self.max_entries_per_sync: int = 10
self.config_path: Optional[Path] = None
-
+
+
# Bot behavior settings (loaded from botrc)
+
self.rate_limit_delay: int = 5
+
self.posts_per_batch: int = 5
+
self.catchup_entries: int = 5
+
self.config_change_notifications: bool = True
+
self.username_claim_notifications: bool = True
+
+
# Track last sync time for schedule queries
+
self.last_sync_time: Optional[float] = None
+
# Debug mode configuration
self.debug_user: Optional[str] = None
self.debug_zulip_user_id: Optional[str] = None
-
+
def usage(self) -> str:
"""Return bot usage instructions."""
return """
**Thicket Feed Bot**
-
+
This bot automatically monitors thicket feeds and posts new articles.
-
+
Commands:
-
- `@mention status` - Show current bot status and configuration
+
- `@mention status` - Show current bot status and configuration
- `@mention sync now` - Force an immediate sync
- `@mention reset` - Clear posting history (will repost recent entries)
- `@mention config stream <stream_name>` - Set target stream
-
- `@mention config topic <topic_name>` - Set target topic
+
- `@mention config topic <topic_name>` - Set target topic
- `@mention config interval <seconds>` - Set sync interval
+
- `@mention schedule` - Show sync schedule and next run time
+
- `@mention claim <username>` - Claim a thicket username for your Zulip account
- `@mention help` - Show this help message
"""
def initialize(self, bot_handler: BotHandler) -> None:
"""Initialize the bot with persistent storage."""
self.logger.info("Initializing ThicketBot")
-
+
# Get configuration from environment (set by CLI)
self.debug_user = os.getenv("THICKET_DEBUG_USER")
config_path_env = os.getenv("THICKET_CONFIG_PATH")
if config_path_env:
self.config_path = Path(config_path_env)
self.logger.info(f"Using thicket config: {self.config_path}")
-
+
+
# Load default configuration from botrc file
+
self._load_botrc_defaults()
+
# Load bot configuration from persistent storage
self._load_bot_config(bot_handler)
-
+
# Initialize thicket components
if self.config_path:
try:
self._initialize_thicket()
self._load_posted_entries(bot_handler)
-
+
# Validate debug mode if enabled
if self.debug_user:
self._validate_debug_mode(bot_handler)
-
+
except Exception as e:
self.logger.error(f"Failed to initialize thicket: {e}")
-
+
# Start background sync loop
self._schedule_sync(bot_handler)
-
def handle_message(self, message: Dict[str, Any], bot_handler: BotHandler) -> None:
+
def handle_message(self, message: dict[str, Any], bot_handler: BotHandler) -> None:
"""Handle incoming Zulip messages."""
content = message["content"].strip()
sender = message["sender_full_name"]
-
+
# Only respond to mentions
if not self._is_mentioned(content, bot_handler):
return
-
+
# Parse command
cleaned_content = self._clean_mention(content, bot_handler)
command_parts = cleaned_content.split()
-
+
if not command_parts:
self._send_help(message, bot_handler)
return
-
+
command = command_parts[0].lower()
-
+
try:
if command == "help":
self._send_help(message, bot_handler)
···
self._handle_reset_command(message, bot_handler, sender)
elif command == "config":
self._handle_config_command(message, bot_handler, command_parts[1:], sender)
+
elif command == "schedule":
+
self._handle_schedule_command(message, bot_handler, sender)
+
elif command == "claim":
+
self._handle_claim_command(message, bot_handler, command_parts[1:], sender)
else:
bot_handler.send_reply(message, f"Unknown command: {command}. Type `@mention help` for usage.")
except Exception as e:
···
return f"@{bot_name}" in content.lower() or f"@**{bot_name}**" in content.lower()
except Exception as e:
self.logger.debug(f"Could not get bot profile: {e}")
-
+
# Fallback to generic check
return "@thicket" in content.lower()
-
+
def _clean_mention(self, content: str, bot_handler: BotHandler) -> str:
"""Remove bot mention from message content."""
import re
-
+
try:
# Get bot's actual name from Zulip
bot_info = bot_handler._client.get_profile()
···
return content
except Exception as e:
self.logger.debug(f"Could not get bot profile for mention cleaning: {e}")
-
+
# Fallback to removing @thicket
content = re.sub(r'@(?:\*\*)?thicket(?:\*\*)?', '', content, flags=re.IGNORECASE).strip()
return content
-
def _send_help(self, message: Dict[str, Any], bot_handler: BotHandler) -> None:
+
def _send_help(self, message: dict[str, Any], bot_handler: BotHandler) -> None:
"""Send help message."""
bot_handler.send_reply(message, self.usage())
-
def _send_status(self, message: Dict[str, Any], bot_handler: BotHandler, sender: str) -> None:
+
def _send_status(self, message: dict[str, Any], bot_handler: BotHandler, sender: str) -> None:
"""Send bot status information."""
status_lines = [
f"**Thicket Bot Status** (requested by {sender})",
"",
]
-
+
# Debug mode status
if self.debug_user:
status_lines.extend([
-
f"🐛 **Debug Mode:** ENABLED",
+
"🐛 **Debug Mode:** ENABLED",
f"🎯 **Debug User:** {self.debug_user}",
"",
])
···
f"📝 **Topic:** {self.topic_name or 'Not configured'}",
"",
])
-
+
status_lines.extend([
f"⏱️ **Sync Interval:** {self.sync_interval}s ({self.sync_interval // 60}m {self.sync_interval % 60}s)",
f"📊 **Max Entries/Sync:** {self.max_entries_per_sync}",
···
f"📄 **Tracked Entries:** {len(self.posted_entries)}",
f"🔄 **Catchup Mode:** {'Active (first run)' if len(self.posted_entries) == 0 else 'Inactive'}",
f"✅ **Thicket Initialized:** {'Yes' if self.git_store else 'No'}",
+
"",
+
self._get_schedule_info(),
])
-
+
bot_handler.send_reply(message, "\n".join(status_lines))
-
def _handle_force_sync(self, message: Dict[str, Any], bot_handler: BotHandler, sender: str) -> None:
+
def _handle_force_sync(self, message: dict[str, Any], bot_handler: BotHandler, sender: str) -> None:
"""Handle immediate sync request."""
if not self._check_initialization(message, bot_handler):
return
-
+
bot_handler.send_reply(message, f"🔄 Starting immediate sync... (requested by {sender})")
-
+
try:
new_entries = self._perform_sync(bot_handler)
bot_handler.send_reply(
-
message,
+
message,
f"✅ Sync completed! Found {len(new_entries)} new entries."
)
except Exception as e:
self.logger.error(f"Force sync failed: {e}")
bot_handler.send_reply(message, f"❌ Sync failed: {str(e)}")
-
def _handle_reset_command(self, message: Dict[str, Any], bot_handler: BotHandler, sender: str) -> None:
+
def _handle_reset_command(self, message: dict[str, Any], bot_handler: BotHandler, sender: str) -> None:
"""Handle reset command to clear posted entries tracking."""
try:
self.posted_entries.clear()
···
self.logger.error(f"Reset failed: {e}")
bot_handler.send_reply(message, f"❌ Reset failed: {str(e)}")
+
def _handle_schedule_command(self, message: dict[str, Any], bot_handler: BotHandler, sender: str) -> None:
+
"""Handle schedule query command."""
+
schedule_info = self._get_schedule_info()
+
bot_handler.send_reply(
+
message,
+
f"**Thicket Bot Schedule** (requested by {sender})\n\n{schedule_info}"
+
)
+
+
def _handle_claim_command(
+
self,
+
message: dict[str, Any],
+
bot_handler: BotHandler,
+
args: list[str],
+
sender: str
+
) -> None:
+
"""Handle username claiming command."""
+
if not args:
+
bot_handler.send_reply(message, "Usage: `@mention claim <username>`")
+
return
+
+
if not self._check_initialization(message, bot_handler):
+
return
+
+
username = args[0].strip()
+
+
# Get sender's Zulip user info
+
sender_user_id = message.get("sender_id")
+
sender_email = message.get("sender_email")
+
+
if not sender_user_id or not sender_email:
+
bot_handler.send_reply(message, "❌ Could not determine your Zulip user information.")
+
return
+
+
try:
+
# Get current Zulip server from environment
+
zulip_site_url = os.getenv("THICKET_ZULIP_SITE_URL", "")
+
server_url = zulip_site_url.replace("https://", "").replace("http://", "")
+
+
if not server_url:
+
bot_handler.send_reply(message, "❌ Could not determine Zulip server URL.")
+
return
+
+
# Check if username exists in thicket
+
user = self.git_store.get_user(username)
+
if not user:
+
bot_handler.send_reply(
+
message,
+
f"❌ Username `{username}` not found in thicket. Available users: {', '.join(self.git_store.list_users())}"
+
)
+
return
+
+
# Check if username is already claimed for this server
+
existing_zulip_id = user.get_zulip_mention(server_url)
+
if existing_zulip_id:
+
# Check if it's claimed by the same user
+
if existing_zulip_id == sender_email or str(existing_zulip_id) == str(sender_user_id):
+
bot_handler.send_reply(
+
message,
+
f"✅ Username `{username}` is already claimed by you on {server_url}!"
+
)
+
else:
+
bot_handler.send_reply(
+
message,
+
f"❌ Username `{username}` is already claimed by another user on {server_url}."
+
)
+
return
+
+
# Claim the username - prefer email for consistency
+
success = self.git_store.add_zulip_association(username, server_url, sender_email)
+
+
if success:
+
reply_msg = f"🎉 Successfully claimed username `{username}` for **{sender}** on {server_url}!\n" + \
+
"You will now be mentioned when new articles are posted from this user's feeds."
+
bot_handler.send_reply(message, reply_msg)
+
+
# Send notification to configured stream if enabled and not in debug mode
+
if (self.username_claim_notifications and
+
not self.debug_user and
+
self.stream_name and self.topic_name):
+
try:
+
notification_msg = f"👋 **{sender}** claimed thicket username `{username}` on {server_url}"
+
bot_handler.send_message({
+
"type": "stream",
+
"to": self.stream_name,
+
"subject": self.topic_name,
+
"content": notification_msg
+
})
+
except Exception as e:
+
self.logger.error(f"Failed to send username claim notification: {e}")
+
+
self.logger.info(f"User {sender} ({sender_email}) claimed username {username} on {server_url}")
+
else:
+
bot_handler.send_reply(
+
message,
+
f"❌ Failed to claim username `{username}`. This shouldn't happen - please contact an administrator."
+
)
+
+
except Exception as e:
+
self.logger.error(f"Error processing claim for {username} by {sender}: {e}")
+
bot_handler.send_reply(message, f"❌ Error processing claim: {str(e)}")
+
def _handle_config_command(
-
self,
-
message: Dict[str, Any],
-
bot_handler: BotHandler,
-
args: List[str],
+
self,
+
message: dict[str, Any],
+
bot_handler: BotHandler,
+
args: list[str],
sender: str
) -> None:
"""Handle configuration commands."""
if len(args) < 2:
bot_handler.send_reply(message, "Usage: `@mention config <setting> <value>`")
return
-
+
setting = args[0].lower()
value = " ".join(args[1:])
-
+
if setting == "stream":
+
old_value = self.stream_name
self.stream_name = value
self._save_bot_config(bot_handler)
bot_handler.send_reply(message, f"✅ Stream set to: **{value}** (by {sender})")
-
+
self._send_config_change_notification(bot_handler, sender, "stream", old_value, value)
+
elif setting == "topic":
+
old_value = self.topic_name
self.topic_name = value
self._save_bot_config(bot_handler)
bot_handler.send_reply(message, f"✅ Topic set to: **{value}** (by {sender})")
-
+
self._send_config_change_notification(bot_handler, sender, "topic", old_value, value)
+
elif setting == "interval":
try:
interval = int(value)
if interval < 60:
bot_handler.send_reply(message, "❌ Interval must be at least 60 seconds")
return
+
old_value = self.sync_interval
self.sync_interval = interval
self._save_bot_config(bot_handler)
bot_handler.send_reply(message, f"✅ Sync interval set to: **{interval}s** (by {sender})")
+
self._send_config_change_notification(bot_handler, sender, "sync interval", f"{old_value}s", f"{interval}s")
except ValueError:
bot_handler.send_reply(message, "❌ Invalid interval value. Must be a number of seconds.")
-
+
+
elif setting == "max_entries":
+
try:
+
max_entries = int(value)
+
if max_entries < 1 or max_entries > 50:
+
bot_handler.send_reply(message, "❌ Max entries must be between 1 and 50")
+
return
+
old_value = self.max_entries_per_sync
+
self.max_entries_per_sync = max_entries
+
self._save_bot_config(bot_handler)
+
bot_handler.send_reply(message, f"✅ Max entries per sync set to: **{max_entries}** (by {sender})")
+
self._send_config_change_notification(bot_handler, sender, "max entries per sync", str(old_value), str(max_entries))
+
except ValueError:
+
bot_handler.send_reply(message, "❌ Invalid max entries value. Must be a number.")
+
else:
bot_handler.send_reply(
-
message,
-
f"❌ Unknown setting: {setting}. Available: stream, topic, interval"
+
message,
+
f"❌ Unknown setting: {setting}. Available: stream, topic, interval, max_entries"
)
def _load_bot_config(self, bot_handler: BotHandler) -> None:
···
if config_data:
config = json.loads(config_data)
self.stream_name = config.get("stream_name")
-
self.topic_name = config.get("topic_name")
+
self.topic_name = config.get("topic_name")
self.sync_interval = config.get("sync_interval", 300)
self.max_entries_per_sync = config.get("max_entries_per_sync", 10)
-
except Exception as e:
+
self.last_sync_time = config.get("last_sync_time")
+
except Exception:
# Bot config not found on first run is expected
pass
···
"topic_name": self.topic_name,
"sync_interval": self.sync_interval,
"max_entries_per_sync": self.max_entries_per_sync,
+
"last_sync_time": self.last_sync_time,
}
bot_handler.storage.put("bot_config", json.dumps(config_data))
except Exception as e:
self.logger.error(f"Error saving bot config: {e}")
+
def _load_botrc_defaults(self) -> None:
+
"""Load default configuration from botrc file."""
+
try:
+
import configparser
+
from pathlib import Path
+
+
botrc_path = Path("bot-config/botrc")
+
if not botrc_path.exists():
+
self.logger.info("No botrc file found, using hardcoded defaults")
+
return
+
+
config = configparser.ConfigParser()
+
config.read(botrc_path)
+
+
if "bot" in config:
+
bot_section = config["bot"]
+
self.sync_interval = bot_section.getint("sync_interval", 300)
+
self.max_entries_per_sync = bot_section.getint("max_entries_per_sync", 10)
+
self.rate_limit_delay = bot_section.getint("rate_limit_delay", 5)
+
self.posts_per_batch = bot_section.getint("posts_per_batch", 5)
+
+
# Set defaults only if not already configured
+
default_stream = bot_section.get("default_stream", "").strip()
+
default_topic = bot_section.get("default_topic", "").strip()
+
if default_stream:
+
self.stream_name = default_stream
+
if default_topic:
+
self.topic_name = default_topic
+
+
if "catchup" in config:
+
catchup_section = config["catchup"]
+
self.catchup_entries = catchup_section.getint("catchup_entries", 5)
+
+
if "notifications" in config:
+
notifications_section = config["notifications"]
+
self.config_change_notifications = notifications_section.getboolean("config_change_notifications", True)
+
self.username_claim_notifications = notifications_section.getboolean("username_claim_notifications", True)
+
+
self.logger.info(f"Loaded configuration from {botrc_path}")
+
+
except Exception as e:
+
self.logger.error(f"Error loading botrc defaults: {e}")
+
self.logger.info("Using hardcoded defaults")
+
def _initialize_thicket(self) -> None:
"""Initialize thicket components."""
if not self.config_path or not self.config_path.exists():
raise ValueError("Thicket config file not found")
-
+
# Load thicket configuration
import yaml
with open(self.config_path) as f:
config_data = yaml.safe_load(f)
self.config = ThicketConfig(**config_data)
-
+
# Initialize git store
self.git_store = GitStore(self.config.git_store)
-
+
self.logger.info("Thicket components initialized successfully")
-
+
def _validate_debug_mode(self, bot_handler: BotHandler) -> None:
"""Validate debug mode configuration."""
if not self.debug_user or not self.git_store:
return
-
+
# Get current Zulip server from environment
zulip_site_url = os.getenv("THICKET_ZULIP_SITE_URL", "")
server_url = zulip_site_url.replace("https://", "").replace("http://", "")
-
+
# Check if debug user exists in thicket
user = self.git_store.get_user(self.debug_user)
if not user:
raise ValueError(f"Debug user '{self.debug_user}' not found in thicket")
-
+
# Check if user has Zulip association for this server
if not server_url:
raise ValueError("Could not determine Zulip server URL")
-
+
zulip_user_id = user.get_zulip_mention(server_url)
if not zulip_user_id:
raise ValueError(f"User '{self.debug_user}' has no Zulip association for server '{server_url}'")
-
+
# Try to look up the actual Zulip user ID from the email address
# But don't fail if we can't - we'll try again when sending messages
actual_user_id = self._lookup_zulip_user_id(bot_handler, zulip_user_id)
···
# If it's already a numeric user ID, return it
if email_or_id.isdigit():
return email_or_id
-
+
try:
client = bot_handler._client
if not client:
self.logger.error("No Zulip client available for user lookup")
return None
-
+
# First try the get_user_by_email API if available
try:
user_result = client.get_user_by_email(email_or_id)
···
return str(user_id)
except (AttributeError, Exception):
pass
-
+
# Fallback: Get all users and search through them
users_result = client.get_users()
if users_result.get('result') == 'success':
for user in users_result['members']:
user_email = user.get('email', '')
delivery_email = user.get('delivery_email', '')
-
-
if (user_email == email_or_id or
+
+
if (user_email == email_or_id or
delivery_email == email_or_id or
str(user.get('user_id')) == email_or_id):
user_id = user.get('user_id')
return str(user_id)
-
+
self.logger.error(f"No user found with identifier '{email_or_id}'. Searched {len(users_result['members'])} users.")
return None
else:
self.logger.error(f"Failed to get users: {users_result.get('msg', 'Unknown error')}")
return None
-
+
except Exception as e:
self.logger.error(f"Error looking up user ID for '{email_or_id}': {e}")
return None
-
def _lookup_zulip_user_info(self, bot_handler: BotHandler, email_or_id: str) -> Tuple[Optional[str], Optional[str]]:
+
def _lookup_zulip_user_info(self, bot_handler: BotHandler, email_or_id: str) -> tuple[Optional[str], Optional[str]]:
"""Look up both Zulip user ID and full name from email address."""
if email_or_id.isdigit():
return email_or_id, None
-
+
try:
client = bot_handler._client
if not client:
return None, None
-
+
# Try get_user_by_email API first
try:
user_result = client.get_user_by_email(email_or_id)
···
return str(user_id), full_name
except AttributeError:
pass
-
+
# Fallback: search all users
users_result = client.get_users()
if users_result.get('result') == 'success':
for user in users_result['members']:
-
if (user.get('email') == email_or_id or
+
if (user.get('email') == email_or_id or
user.get('delivery_email') == email_or_id):
return str(user.get('user_id')), user.get('full_name', '')
-
+
return None, None
-
+
except Exception as e:
self.logger.error(f"Error looking up user info for '{email_or_id}': {e}")
return None, None
···
except Exception as e:
self.logger.error(f"Error saving posted entries: {e}")
-
def _check_initialization(self, message: Dict[str, Any], bot_handler: BotHandler) -> bool:
+
def _check_initialization(self, message: dict[str, Any], bot_handler: BotHandler) -> bool:
"""Check if thicket is properly initialized."""
if not self.git_store or not self.config:
bot_handler.send_reply(
-
message,
+
message,
"❌ Thicket not initialized. Please check configuration."
)
return False
-
+
# In debug mode, we don't need stream/topic configuration
if self.debug_user:
return True
-
+
if not self.stream_name or not self.topic_name:
bot_handler.send_reply(
message,
"❌ Stream and topic must be configured first. Use `@mention config stream <name>` and `@mention config topic <name>`"
)
return False
-
+
return True
def _schedule_sync(self, bot_handler: BotHandler) -> None:
···
while True:
try:
# Check if we can sync
-
can_sync = (self.git_store and
-
((self.stream_name and self.topic_name) or
+
can_sync = (self.git_store and
+
((self.stream_name and self.topic_name) or
self.debug_user))
-
+
if can_sync:
self._perform_sync(bot_handler)
-
+
time.sleep(self.sync_interval)
except Exception as e:
self.logger.error(f"Error in sync loop: {e}")
time.sleep(60) # Wait before retrying
-
+
# Start background thread
import threading
sync_thread = threading.Thread(target=sync_loop, daemon=True)
sync_thread.start()
-
def _perform_sync(self, bot_handler: BotHandler) -> List[AtomEntry]:
+
def _perform_sync(self, bot_handler: BotHandler) -> list[AtomEntry]:
"""Perform thicket sync and return new entries."""
if not self.config or not self.git_store:
return []
-
-
new_entries: List[Tuple[AtomEntry, str]] = [] # (entry, username) pairs
+
+
new_entries: list[tuple[AtomEntry, str]] = [] # (entry, username) pairs
is_first_run = len(self.posted_entries) == 0
-
+
# Get all users and their feeds from git store
users_with_feeds = self.git_store.list_all_users_with_feeds()
-
+
# Sync each user's feeds
for username, feed_urls in users_with_feeds:
for feed_url in feed_urls:
···
new_count, _ = loop.run_until_complete(
sync_feed(self.git_store, username, str(feed_url), dry_run=False)
)
-
+
entries_to_check = []
-
+
if new_count > 0:
# Get the newly added entries
entries_to_check = self.git_store.list_entries(username, limit=new_count)
-
+
# Always check for catchup mode on first run
if is_first_run:
-
# Catchup mode: get last 5 entries on first run
-
catchup_entries = self.git_store.list_entries(username, limit=5)
+
# Catchup mode: get configured number of entries on first run
+
catchup_entries = self.git_store.list_entries(username, limit=self.catchup_entries)
entries_to_check = catchup_entries if not entries_to_check else entries_to_check
-
+
for entry in entries_to_check:
entry_key = f"{username}:{entry.id}"
if entry_key not in self.posted_entries:
new_entries.append((entry, username))
if len(new_entries) >= self.max_entries_per_sync:
break
-
+
finally:
loop.close()
-
+
except Exception as e:
self.logger.error(f"Error syncing feed {feed_url} for user {username}: {e}")
-
+
if len(new_entries) >= self.max_entries_per_sync:
break
-
+
# Post new entries to Zulip with rate limiting
if new_entries:
posted_count = 0
-
+
for i, (entry, username) in enumerate(new_entries):
self._post_entry_to_zulip(entry, bot_handler, username)
self.posted_entries.add(f"{username}:{entry.id}")
posted_count += 1
-
-
# Rate limiting: pause after every 5 messages
-
if posted_count % 5 == 0 and i < len(new_entries) - 1:
-
time.sleep(5)
-
+
+
# Rate limiting: pause after configured number of messages
+
if posted_count % self.posts_per_batch == 0 and i < len(new_entries) - 1:
+
time.sleep(self.rate_limit_delay)
+
self._save_posted_entries(bot_handler)
-
+
+
# Update last sync time
+
self.last_sync_time = time.time()
+
return [entry for entry, _ in new_entries]
def _post_entry_to_zulip(self, entry: AtomEntry, bot_handler: BotHandler, username: str) -> None:
···
# Get current Zulip server from environment
zulip_site_url = os.getenv("THICKET_ZULIP_SITE_URL", "")
server_url = zulip_site_url.replace("https://", "").replace("http://", "")
-
+
# Build author/date info consistently
mention_info = ""
if server_url and self.git_store:
···
# Look up the actual Zulip full name for proper @mention
_, zulip_full_name = self._lookup_zulip_user_info(bot_handler, zulip_user_id)
display_name = zulip_full_name or user.display_name or username
-
+
# Check if author is different from the user - avoid redundancy
author_name = entry.author and entry.author.get("name")
if author_name and author_name.lower() != display_name.lower():
author_info = f" (by {author_name})"
else:
author_info = ""
-
+
published_info = ""
if entry.published:
published_info = f" • {entry.published.strftime('%Y-%m-%d')}"
-
+
mention_info = f"@**{display_name}** posted{author_info}{published_info}:\n\n"
-
+
# If no Zulip user found, use consistent format without @mention
if not mention_info:
user = self.git_store.get_user(username) if self.git_store else None
display_name = user.display_name if user else username
-
+
author_name = entry.author and entry.author.get("name")
if author_name and author_name.lower() != display_name.lower():
author_info = f" (by {author_name})"
else:
author_info = ""
-
+
published_info = ""
if entry.published:
published_info = f" • {entry.published.strftime('%Y-%m-%d')}"
-
+
mention_info = f"**{display_name}** posted{author_info}{published_info}:\n\n"
-
+
# Format the message with HTML processing
message_lines = [
f"**{entry.title}**",
f"🔗 {entry.link}",
]
-
+
if entry.summary:
# Process HTML in summary and truncate if needed
processed_summary = self._process_html_content(entry.summary)
if len(processed_summary) > 400:
processed_summary = processed_summary[:397] + "..."
message_lines.append(f"\n{processed_summary}")
-
+
message_content = mention_info + "\n".join(message_lines)
-
+
# Choose destination based on mode
if self.debug_user and self.debug_zulip_user_id:
# Debug mode: send DM
debug_message = f"🐛 **DEBUG:** New article from thicket user `{username}`:\n\n{message_content}"
-
+
# Ensure we have the numeric user ID
user_id_to_use = self.debug_zulip_user_id
if not user_id_to_use.isdigit():
···
else:
self.logger.error(f"Could not resolve user ID for {self.debug_zulip_user_id}")
return
-
+
try:
# For private messages, user_id needs to be an integer, not string
user_id_int = int(user_id_to_use)
···
# If conversion to int fails, user_id_to_use might be an email
try:
bot_handler.send_message({
-
"type": "private",
+
"type": "private",
"to": [user_id_to_use], # Try as string (email)
"content": debug_message
})
···
"content": message_content
})
self.logger.info(f"Posted entry to stream: {entry.title} (user: {username})")
-
+
except Exception as e:
self.logger.error(f"Error posting entry to Zulip: {e}")
···
"""Process HTML content from feeds to clean Zulip-compatible markdown."""
if not html_content:
return ""
-
+
try:
# Try to use markdownify for proper HTML to Markdown conversion
from markdownify import markdownify as md
-
+
# Convert HTML to Markdown with compact settings for summaries
markdown = md(
html_content,
···
bullets="-", # Use - for bullets
convert=['a', 'b', 'strong', 'i', 'em', 'code', 'pre', 'p', 'br', 'ul', 'ol', 'li', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6']
).strip()
-
+
# Post-process to convert headings to bold for compact summaries
import re
# Convert markdown headers to bold with period
markdown = re.sub(r'^#{1,6}\s*(.+)$', r'**\1.**', markdown, flags=re.MULTILINE)
-
+
# Clean up excessive newlines and make more compact
markdown = re.sub(r'\n\s*\n\s*\n+', ' ', markdown) # Multiple newlines become space
markdown = re.sub(r'\n\s*\n', '. ', markdown) # Double newlines become sentence breaks
markdown = re.sub(r'\n', ' ', markdown) # Single newlines become spaces
-
+
# Clean up double periods and excessive whitespace
markdown = re.sub(r'\.\.+', '.', markdown)
markdown = re.sub(r'\s+', ' ', markdown)
return markdown.strip()
-
+
except ImportError:
# Fallback: manual HTML processing
import re
content = html_content
-
+
# Convert headings to bold with periods for compact summaries
content = re.sub(r'<h[1-6](?:\s[^>]*)?>([^<]*)</h[1-6]>', r'**\1.** ', content, flags=re.IGNORECASE)
-
+
# Convert common HTML elements to Markdown
content = re.sub(r'<(?:strong|b)(?:\s[^>]*)?>([^<]*)</(?:strong|b)>', r'**\1**', content, flags=re.IGNORECASE)
content = re.sub(r'<(?:em|i)(?:\s[^>]*)?>([^<]*)</(?:em|i)>', r'*\1*', content, flags=re.IGNORECASE)
content = re.sub(r'<code(?:\s[^>]*)?>([^<]*)</code>', r'`\1`', content, flags=re.IGNORECASE)
content = re.sub(r'<a(?:\s[^>]*?)?\s*href=["\']([^"\']*)["\'](?:\s[^>]*)?>([^<]*)</a>', r'[\2](\1)', content, flags=re.IGNORECASE)
-
+
# Convert block elements to spaces instead of newlines for compactness
content = re.sub(r'<br\s*/?>', ' ', content, flags=re.IGNORECASE)
content = re.sub(r'</p>\s*<p>', '. ', content, flags=re.IGNORECASE)
content = re.sub(r'</?(?:p|div)(?:\s[^>]*)?>', ' ', content, flags=re.IGNORECASE)
-
+
# Remove remaining HTML tags
content = re.sub(r'<[^>]+>', '', content)
-
+
# Clean up whitespace and make compact
content = re.sub(r'\s+', ' ', content) # Multiple whitespace becomes single space
content = re.sub(r'\.\.+', '.', content) # Multiple periods become single period
return content.strip()
-
+
except Exception as e:
self.logger.error(f"Error processing HTML content: {e}")
# Last resort: just strip HTML tags
import re
return re.sub(r'<[^>]+>', '', html_content).strip()
+
def _get_schedule_info(self) -> str:
+
"""Get schedule information string."""
+
lines = []
-
handler_class = ThicketBotHandler
+
if self.last_sync_time:
+
import datetime
+
last_sync = datetime.datetime.fromtimestamp(self.last_sync_time)
+
next_sync = last_sync + datetime.timedelta(seconds=self.sync_interval)
+
now = datetime.datetime.now()
+
+
# Calculate time until next sync
+
time_until_next = next_sync - now
+
+
if time_until_next.total_seconds() > 0:
+
minutes, seconds = divmod(int(time_until_next.total_seconds()), 60)
+
hours, minutes = divmod(minutes, 60)
+
+
if hours > 0:
+
time_str = f"{hours}h {minutes}m {seconds}s"
+
elif minutes > 0:
+
time_str = f"{minutes}m {seconds}s"
+
else:
+
time_str = f"{seconds}s"
+
+
lines.extend([
+
f"🕐 **Last Sync:** {last_sync.strftime('%H:%M:%S')}",
+
f"⏰ **Next Sync:** {next_sync.strftime('%H:%M:%S')} (in {time_str})",
+
])
+
else:
+
lines.extend([
+
f"🕐 **Last Sync:** {last_sync.strftime('%H:%M:%S')}",
+
f"⏰ **Next Sync:** Due now (running every {self.sync_interval}s)",
+
])
+
else:
+
lines.append("🕐 **Last Sync:** Never (bot starting up)")
+
+
# Add sync frequency info
+
if self.sync_interval >= 3600:
+
frequency_str = f"{self.sync_interval // 3600}h {(self.sync_interval % 3600) // 60}m"
+
elif self.sync_interval >= 60:
+
frequency_str = f"{self.sync_interval // 60}m {self.sync_interval % 60}s"
+
else:
+
frequency_str = f"{self.sync_interval}s"
+
+
lines.append(f"🔄 **Sync Frequency:** Every {frequency_str}")
+
+
return "\n".join(lines)
+
+
def _send_config_change_notification(self, bot_handler: BotHandler, changer: str, setting: str, old_value: Optional[str], new_value: str) -> None:
+
"""Send configuration change notification if enabled."""
+
if not self.config_change_notifications or self.debug_user:
+
return
+
+
# Don't send notification if stream/topic aren't configured yet
+
if not self.stream_name or not self.topic_name:
+
return
+
+
try:
+
old_display = old_value if old_value else "(not set)"
+
notification_msg = f"⚙️ **{changer}** changed {setting}: `{old_display}` → `{new_value}`"
+
+
bot_handler.send_message({
+
"type": "stream",
+
"to": self.stream_name,
+
"subject": self.topic_name,
+
"content": notification_msg
+
})
+
except Exception as e:
+
self.logger.error(f"Failed to send config change notification: {e}")
+
+
+
handler_class = ThicketBotHandler
+
+12 -1
src/thicket/cli/commands/__init__.py
···
"""CLI commands for thicket."""
# Import all commands to register them with the main app
-
from . import add, bot, duplicates, info_cmd, init, list_cmd, search, sync, upload, zulip
+
from . import (
+
add,
+
bot,
+
duplicates,
+
info_cmd,
+
init,
+
list_cmd,
+
search,
+
sync,
+
upload,
+
zulip,
+
)
__all__ = ["add", "bot", "duplicates", "info_cmd", "init", "list_cmd", "search", "sync", "upload", "zulip"]
+35 -33
src/thicket/cli/commands/bot.py
···
daemon: bool = typer.Option(
False,
"--daemon",
-
"-d",
+
"-d",
help="Run bot in daemon mode (background)",
),
debug_user: str = typer.Option(
···
- test: Test bot functionality
- status: Show bot status
"""
-
+
if action == "run":
_run_bot(config_file, thicket_config, daemon, debug_user)
elif action == "test":
···
if not config_file.exists():
print_error(f"Configuration file not found: {config_file}")
print_info(f"Copy bot-config/zuliprc.template to {config_file} and configure it")
+
print_info("See bot-config/README.md for setup instructions")
raise typer.Exit(1)
-
+
if not thicket_config.exists():
print_error(f"Thicket configuration file not found: {thicket_config}")
print_info("Run `thicket init` to create a thicket.yaml file")
raise typer.Exit(1)
-
+
# Parse zuliprc to extract server URL
zulip_site_url = _parse_zulip_config(config_file)
-
+
print_info(f"Starting Thicket Zulip bot with config: {config_file}")
print_info(f"Using thicket config: {thicket_config}")
-
+
if debug_user:
print_info(f"🐛 DEBUG MODE: Will send DMs to thicket user '{debug_user}' instead of posting to streams")
-
+
if daemon:
print_info("Running in daemon mode...")
else:
print_info("Bot will be available as @thicket in your Zulip chat")
print_info("Press Ctrl+C to stop the bot")
-
+
try:
# Build the command
cmd = [
···
"src/thicket/bots/thicket_bot.py",
"--config-file", str(config_file)
]
-
+
# Add environment variables for bot configuration
import os
env = os.environ.copy()
-
+
# Always pass thicket config path
env["THICKET_CONFIG_PATH"] = str(thicket_config.absolute())
-
+
# Add debug user if specified
if debug_user:
env["THICKET_DEBUG_USER"] = debug_user
-
+
# Pass Zulip server URL to bot
if zulip_site_url:
env["THICKET_ZULIP_SITE_URL"] = zulip_site_url
-
+
if daemon:
# Run in background
process = subprocess.Popen(
···
else:
# Run in foreground
subprocess.run(cmd, check=True, env=env)
-
+
except subprocess.CalledProcessError as e:
print_error(f"Failed to start bot: {e}")
raise typer.Exit(1)
···
"""Parse zuliprc file to extract the site URL."""
try:
import configparser
-
+
config = configparser.ConfigParser()
config.read(config_file)
-
+
if "api" in config and "site" in config["api"]:
site_url = config["api"]["site"]
print_info(f"Detected Zulip server: {site_url}")
···
else:
print_error("Could not find 'site' in zuliprc [api] section")
return ""
-
+
except Exception as e:
print_error(f"Error parsing zuliprc: {e}")
return ""
···
def _test_bot() -> None:
"""Test bot functionality."""
print_info("Testing Thicket Zulip bot...")
-
+
try:
from ...bots.test_bot import BotTester
-
+
# Create bot tester
tester = BotTester()
-
+
# Test basic functionality
console.print("✓ Testing help command...", style="green")
responses = tester.send_command("help")
assert len(responses) == 1
assert "Thicket Feed Bot" in tester.get_last_response_content()
-
-
console.print("✓ Testing status command...", style="green")
+
+
console.print("✓ Testing status command...", style="green")
responses = tester.send_command("status")
assert len(responses) == 1
assert "Status" in tester.get_last_response_content()
-
+
console.print("✓ Testing config commands...", style="green")
responses = tester.send_command("config stream test-stream")
tester.assert_response_contains("Stream set to")
-
+
responses = tester.send_command("config topic test-topic")
tester.assert_response_contains("Topic set to")
-
+
responses = tester.send_command("config interval 300")
tester.assert_response_contains("Sync interval set to")
-
+
print_success("All bot tests passed!")
-
+
except Exception as e:
print_error(f"Bot test failed: {e}")
raise typer.Exit(1)
···
"""Show bot status."""
console.print("Thicket Zulip Bot Status", style="bold blue")
console.print()
-
+
# Check config file
if config_file.exists():
console.print(f"✓ Config file: {config_file}", style="green")
else:
console.print(f"✗ Config file not found: {config_file}", style="red")
console.print(" Copy bot-config/zuliprc.template and configure it", style="yellow")
-
+
console.print(" See bot-config/README.md for setup instructions", style="yellow")
+
# Check dependencies
try:
import zulip_bots
···
console.print(f"✓ zulip-bots version: {version}", style="green")
except ImportError:
console.print("✗ zulip-bots not installed", style="red")
-
+
try:
from ...bots.thicket_bot import ThicketBotHandler
console.print("✓ ThicketBotHandler available", style="green")
except ImportError as e:
console.print(f"✗ Bot handler not available: {e}", style="red")
-
+
# Check bot file
bot_file = Path("src/thicket/bots/thicket_bot.py")
if bot_file.exists():
console.print(f"✓ Bot file: {bot_file}", style="green")
else:
console.print(f"✗ Bot file not found: {bot_file}", style="red")
-
+
console.print()
console.print("To run the bot:", style="bold")
console.print(f" thicket bot run --config {config_file}")
console.print()
-
console.print("For help setting up the bot, see: docs/ZULIP_BOT.md", style="dim")
+
console.print("For help setting up the bot, see: docs/ZULIP_BOT.md", style="dim")
+18 -20
src/thicket/cli/commands/search.py
···
import typer
from rich.console import Console
from rich.table import Table
-
from rich.text import Text
from ...core.typesense_client import TypesenseClient, TypesenseConfig
from ..main import app
-
from ..utils import load_config
console = Console()
logger = logging.getLogger(__name__)
···
)
typesense_config.connection_timeout = timeout
-
console.print(f"[bold blue]Searching thicket entries[/bold blue]")
+
console.print("[bold blue]Searching thicket entries[/bold blue]")
console.print(f"Query: [cyan]{query}[/cyan]")
if user:
console.print(f"User filter: [yellow]{user}[/yellow]")
···
# Perform search
try:
results = typesense_client.search(query, search_params)
-
+
if raw:
import json
console.print(json.dumps(results, indent=2))
···
return
console.print(f"\n[green]Found {found} results in {search_time}ms[/green]")
-
+
table = Table(title=f"Search Results for '{query}'", show_lines=True)
table.add_column("Score", style="green", width=8, no_wrap=True)
table.add_column("User", style="cyan", width=15, no_wrap=True)
···
for hit in hits:
doc = hit['document']
-
+
# Format score
score = f"{hit.get('text_match', 0):.2f}"
-
+
# Format user
user_display = doc.get('user_display_name', doc.get('username', 'Unknown'))
if len(user_display) > 12:
user_display = user_display[:9] + "..."
-
+
# Format title
title = doc.get('title', 'Untitled')
if len(title) > 40:
title = title[:37] + "..."
-
+
# Format date
updated_timestamp = doc.get('updated', 0)
if updated_timestamp:
···
updated_str = updated_date.strftime("%Y-%m-%d")
else:
updated_str = "Unknown"
-
+
# Format summary
summary = doc.get('summary') or doc.get('content', '')
if summary:
···
)
console.print(table)
-
+
# Show additional info
console.print(f"\n[dim]Showing {len(hits)} of {found} results[/dim]")
if len(hits) < found:
···
"""Display search results in a compact format."""
hits = results.get('hits', [])
found = results.get('found', 0)
-
+
if not hits:
console.print("\n[yellow]No results found.[/yellow]")
return
console.print(f"\n[green]Found {found} results[/green]\n")
-
+
for i, hit in enumerate(hits, 1):
doc = hit['document']
score = hit.get('text_match', 0)
-
+
# Header with score and user
user = doc.get('user_display_name', doc.get('username', 'Unknown'))
console.print(f"[green]{i:2d}.[/green] [cyan]{user}[/cyan] [dim](score: {score:.2f})[/dim]")
-
+
# Title
title = doc.get('title', 'Untitled')
console.print(f" [bold]{title}[/bold]")
-
+
# Date and link
updated_timestamp = doc.get('updated', 0)
if updated_timestamp:
···
updated_str = updated_date.strftime("%Y-%m-%d %H:%M")
else:
updated_str = "Unknown date"
-
+
link = doc.get('link', '')
console.print(f" [blue]{updated_str}[/blue] - [link={link}]{link}[/link]")
-
+
# Summary
summary = doc.get('summary') or doc.get('content', '')
if summary:
···
if len(summary) > 150:
summary = summary[:147] + "..."
console.print(f" [dim]{summary}[/dim]")
-
-
console.print() # Empty line between results
+
+
console.print() # Empty line between results
+30 -30
src/thicket/cli/commands/zulip.py
···
try:
config = load_config(config_file)
git_store = GitStore(config.git_store)
-
+
# Check if user exists
user = git_store.get_user(username)
if not user:
print_error(f"User '{username}' not found")
raise typer.Exit(1)
-
+
# Add association
if git_store.add_zulip_association(username, server, user_id):
print_success(f"Added Zulip association for {username}: {user_id}@{server}")
git_store.commit_changes(f"Add Zulip association for {username}")
else:
print_info(f"Association already exists for {username}: {user_id}@{server}")
-
+
except Exception as e:
print_error(f"Failed to add Zulip association: {e}")
raise typer.Exit(1)
···
try:
config = load_config(config_file)
git_store = GitStore(config.git_store)
-
+
# Check if user exists
user = git_store.get_user(username)
if not user:
print_error(f"User '{username}' not found")
raise typer.Exit(1)
-
+
# Remove association
if git_store.remove_zulip_association(username, server, user_id):
print_success(f"Removed Zulip association for {username}: {user_id}@{server}")
···
else:
print_error(f"Association not found for {username}: {user_id}@{server}")
raise typer.Exit(1)
-
+
except Exception as e:
print_error(f"Failed to remove Zulip association: {e}")
raise typer.Exit(1)
···
try:
config = load_config(config_file)
git_store = GitStore(config.git_store)
-
+
# Create table
table = Table(title="Zulip Associations")
table.add_column("Username", style="cyan")
table.add_column("Server", style="green")
table.add_column("User ID", style="yellow")
-
+
if username:
# List for specific user
user = git_store.get_user(username)
if not user:
print_error(f"User '{username}' not found")
raise typer.Exit(1)
-
+
if not user.zulip_associations:
print_info(f"No Zulip associations for {username}")
return
-
+
for assoc in user.zulip_associations:
table.add_row(username, assoc.server, assoc.user_id)
else:
# List for all users
index = git_store._load_index()
has_associations = False
-
+
for username, user in index.users.items():
for assoc in user.zulip_associations:
table.add_row(username, assoc.server, assoc.user_id)
has_associations = True
-
+
if not has_associations:
print_info("No Zulip associations found")
return
-
+
console.print(table)
-
+
except Exception as e:
print_error(f"Failed to list Zulip associations: {e}")
raise typer.Exit(1)
···
thicket zulip-import associations.csv
"""
import csv
-
+
try:
config = load_config(config_file)
git_store = GitStore(config.git_store)
-
+
if not csv_file.exists():
print_error(f"CSV file not found: {csv_file}")
raise typer.Exit(1)
-
+
added = 0
skipped = 0
errors = 0
-
-
with open(csv_file, 'r') as f:
+
+
with open(csv_file) as f:
reader = csv.reader(f)
for row_num, row in enumerate(reader, 1):
if len(row) != 3:
print_error(f"Line {row_num}: Invalid format (expected 3 columns)")
errors += 1
continue
-
+
username, server, user_id = [col.strip() for col in row]
-
+
# Skip empty lines
if not username:
continue
-
+
# Check if user exists
user = git_store.get_user(username)
if not user:
print_error(f"Line {row_num}: User '{username}' not found")
errors += 1
continue
-
+
if dry_run:
# Check if association would be added
exists = any(
-
a.server == server and a.user_id == user_id
+
a.server == server and a.user_id == user_id
for a in user.zulip_associations
)
if exists:
···
else:
print_info(f"Skipped existing: {username} -> {user_id}@{server}")
skipped += 1
-
+
# Summary
console.print()
if dry_run:
-
console.print(f"[bold]Dry run summary:[/bold]")
+
console.print("[bold]Dry run summary:[/bold]")
console.print(f" Would add: {added}")
else:
-
console.print(f"[bold]Import summary:[/bold]")
+
console.print("[bold]Import summary:[/bold]")
console.print(f" Added: {added}")
if not dry_run and added > 0:
git_store.commit_changes(f"Import {added} Zulip associations from CSV")
-
+
console.print(f" Skipped: {skipped}")
console.print(f" Errors: {errors}")
-
+
except Exception as e:
print_error(f"Failed to import Zulip associations: {e}")
-
raise typer.Exit(1)
+
raise typer.Exit(1)
+9 -1
src/thicket/cli/main.py
···
# Import commands to register them
-
from .commands import add, duplicates, info_cmd, init, list_cmd, sync, upload # noqa: F401
+
from .commands import ( # noqa: F401
+
add,
+
duplicates,
+
info_cmd,
+
init,
+
list_cmd,
+
sync,
+
upload,
+
)
if __name__ == "__main__":
app()
+10 -10
src/thicket/core/git_store.py
···
self._save_index(index)
return True
-
+
def add_zulip_association(self, username: str, server: str, user_id: str) -> bool:
"""Add a Zulip association to a user."""
index = self._load_index()
user = index.get_user(username)
-
+
if not user:
return False
-
+
result = user.add_zulip_association(server, user_id)
if result:
index.add_user(user)
self._save_index(index)
-
+
return result
-
+
def remove_zulip_association(self, username: str, server: str, user_id: str) -> bool:
"""Remove a Zulip association from a user."""
index = self._load_index()
user = index.get_user(username)
-
+
if not user:
return False
-
+
result = user.remove_zulip_association(server, user_id)
if result:
index.add_user(user)
self._save_index(index)
-
+
return result
-
+
def get_zulip_associations(self, username: str) -> list:
"""Get all Zulip associations for a user."""
user = self.get_user(username)
···
user = self.get_user(username)
if not user:
return []
-
+
# Feed URLs are stored in the user metadata
return user.feeds
+5 -5
src/thicket/models/user.py
···
class ZulipAssociation(BaseModel):
"""Association between a user and their Zulip identity."""
-
+
server: str # Zulip server URL (e.g., "yourorg.zulipchat.com")
user_id: str # Zulip user ID or email for @mentions
-
+
def __hash__(self) -> int:
"""Make hashable for use in sets."""
return hash((self.server, self.user_id))
···
"""Increment the entry count by the given amount."""
self.entry_count += count
self.update_timestamp()
-
+
def add_zulip_association(self, server: str, user_id: str) -> bool:
"""Add a Zulip association if it doesn't exist. Returns True if added."""
association = ZulipAssociation(server=server, user_id=user_id)
···
self.update_timestamp()
return True
return False
-
+
def remove_zulip_association(self, server: str, user_id: str) -> bool:
"""Remove a Zulip association. Returns True if removed."""
association = ZulipAssociation(server=server, user_id=user_id)
···
self.update_timestamp()
return True
return False
-
+
def get_zulip_mention(self, server: str) -> Optional[str]:
"""Get the Zulip user_id for @mentions on a specific server."""
for association in self.zulip_associations:
+50 -50
tests/test_bot.py
···
"""Tests for the Thicket Zulip bot."""
-
import json
-
import tempfile
-
from pathlib import Path
-
from unittest.mock import patch
import pytest
-
from thicket.bots.test_bot import BotTester, MockBotHandler, create_test_message, create_test_entry
+
from thicket.bots.test_bot import (
+
BotTester,
+
MockBotHandler,
+
create_test_entry,
+
create_test_message,
+
)
from thicket.bots.thicket_bot import ThicketBotHandler
···
"""Test help command response."""
message = create_test_message("@thicket help")
self.bot.handle_message(message, self.handler)
-
+
assert len(self.handler.sent_messages) == 1
response = self.handler.sent_messages[0]["content"]
assert "Thicket Feed Bot" in response
···
"""Test status command when bot is not configured."""
message = create_test_message("@thicket status")
self.bot.handle_message(message, self.handler)
-
+
assert len(self.handler.sent_messages) == 1
response = self.handler.sent_messages[0]["content"]
assert "Not configured" in response
···
"""Test setting stream configuration."""
message = create_test_message("@thicket config stream general")
self.bot.handle_message(message, self.handler)
-
+
assert len(self.handler.sent_messages) == 1
response = self.handler.sent_messages[0]["content"]
assert "Stream set to: **general**" in response
···
"""Test setting topic configuration."""
message = create_test_message("@thicket config topic 'Feed Updates'")
self.bot.handle_message(message, self.handler)
-
+
assert len(self.handler.sent_messages) == 1
response = self.handler.sent_messages[0]["content"]
assert "Topic set to:" in response and "Feed Updates" in response
···
"""Test setting sync interval."""
message = create_test_message("@thicket config interval 600")
self.bot.handle_message(message, self.handler)
-
+
assert len(self.handler.sent_messages) == 1
response = self.handler.sent_messages[0]["content"]
assert "Sync interval set to: **600s**" in response
···
"""Test setting sync interval that's too small."""
message = create_test_message("@thicket config interval 30")
self.bot.handle_message(message, self.handler)
-
+
assert len(self.handler.sent_messages) == 1
response = self.handler.sent_messages[0]["content"]
assert "must be at least 60 seconds" in response
···
"""Test setting config path that doesn't exist."""
message = create_test_message("@thicket config path /nonexistent/config.yaml")
self.bot.handle_message(message, self.handler)
-
+
assert len(self.handler.sent_messages) == 1
response = self.handler.sent_messages[0]["content"]
assert "Config file not found" in response
···
"""Test unknown command handling."""
message = create_test_message("@thicket unknown")
self.bot.handle_message(message, self.handler)
-
+
assert len(self.handler.sent_messages) == 1
response = self.handler.sent_messages[0]["content"]
assert "Unknown command: unknown" in response
···
self.bot.stream_name = "test-stream"
self.bot.topic_name = "test-topic"
self.bot.sync_interval = 600
-
+
# Save config
self.bot._save_bot_config(self.handler)
-
+
# Create new bot instance
new_bot = ThicketBotHandler()
new_bot._load_bot_config(self.handler)
-
+
# Check config was loaded
assert new_bot.stream_name == "test-stream"
assert new_bot.topic_name == "test-topic"
···
"""Test that posted entries are persisted."""
# Add some entries
self.bot.posted_entries = {"user1:entry1", "user2:entry2"}
-
+
# Save entries
self.bot._save_posted_entries(self.handler)
-
+
# Create new bot instance
new_bot = ThicketBotHandler()
new_bot._load_posted_entries(self.handler)
-
+
# Check entries were loaded
assert new_bot.posted_entries == {"user1:entry1", "user2:entry2"}
···
"""Test cleaning mentions from messages."""
cleaned = self.bot._clean_mention("@Thicket Bot status", self.handler)
assert cleaned == "status"
-
+
cleaned = self.bot._clean_mention("@thicket help", self.handler)
assert cleaned == "help"
···
"""Test sync now command when not initialized."""
message = create_test_message("@thicket sync now")
self.bot.handle_message(message, self.handler)
-
+
assert len(self.handler.sent_messages) == 1
response = self.handler.sent_messages[0]["content"]
assert "not initialized" in response.lower()
-
+
def test_debug_mode_initialization(self) -> None:
"""Test debug mode initialization."""
import os
-
+
# Mock environment variable
os.environ["THICKET_DEBUG_USER"] = "testuser"
-
+
try:
bot = ThicketBotHandler()
# Simulate initialize call
bot.debug_user = os.getenv("THICKET_DEBUG_USER")
-
+
assert bot.debug_user == "testuser"
assert bot.debug_zulip_user_id is None # Not validated yet
finally:
# Clean up
if "THICKET_DEBUG_USER" in os.environ:
del os.environ["THICKET_DEBUG_USER"]
-
+
def test_debug_mode_status(self) -> None:
"""Test status command in debug mode."""
self.bot.debug_user = "testuser"
self.bot.debug_zulip_user_id = "test.user"
-
+
message = create_test_message("@thicket status")
self.bot.handle_message(message, self.handler)
-
+
assert len(self.handler.sent_messages) == 1
response = self.handler.sent_messages[0]["content"]
assert "**Debug Mode:** ENABLED" in response
assert "**Debug User:** testuser" in response
assert "**Debug Zulip ID:** test.user" in response
-
+
def test_debug_mode_check_initialization(self) -> None:
"""Test initialization check in debug mode."""
from unittest.mock import Mock
-
+
# Setup mock git store and config
self.bot.git_store = Mock()
self.bot.config = Mock()
self.bot.debug_user = "testuser"
self.bot.debug_zulip_user_id = "test.user"
-
+
message = create_test_message("@thicket sync now")
-
+
# Should pass with debug mode properly set up
result = self.bot._check_initialization(message, self.handler)
assert result is True
-
+
# Should fail if debug_zulip_user_id is missing
self.bot.debug_zulip_user_id = None
result = self.bot._check_initialization(message, self.handler)
assert result is False
assert len(self.handler.sent_messages) == 1
assert "Debug mode validation failed" in self.handler.sent_messages[0]["content"]
-
+
def test_debug_mode_dm_posting(self) -> None:
"""Test that debug mode posts DMs instead of stream messages."""
from unittest.mock import Mock
-
from datetime import datetime
-
from pydantic import HttpUrl
-
+
+
# Setup bot in debug mode
self.bot.debug_user = "testuser"
self.bot.debug_zulip_user_id = "test.user@example.com"
self.bot.git_store = Mock()
-
+
# Create a test entry
entry = create_test_entry()
-
+
# Mock the handler config
self.handler.config_info = {
"full_name": "Thicket Bot",
"email": "thicket-bot@example.com",
"site": "https://example.zulipchat.com"
}
-
+
# Mock git store user
mock_user = Mock()
mock_user.get_zulip_mention.return_value = "author.user"
self.bot.git_store.get_user.return_value = mock_user
-
+
# Post entry
self.bot._post_entry_to_zulip(entry, self.handler, "testauthor")
-
+
# Check that a DM was sent
assert len(self.handler.sent_messages) == 1
message = self.handler.sent_messages[0]
-
+
# Verify it's a DM
assert message["type"] == "private"
assert message["to"] == ["test.user@example.com"]
···
def test_bot_tester_basic(self) -> None:
"""Test basic bot tester functionality."""
tester = BotTester()
-
+
# Test help command
responses = tester.send_command("help")
assert len(responses) == 1
···
def test_bot_tester_config(self) -> None:
"""Test bot tester configuration."""
tester = BotTester()
-
+
# Configure stream
responses = tester.send_command("config stream general")
tester.assert_response_contains("Stream set to")
-
+
# Configure topic
responses = tester.send_command("config topic test")
tester.assert_response_contains("Topic set to")
···
def test_assert_response_contains(self) -> None:
"""Test response assertion helper."""
tester = BotTester()
-
+
# Send command
tester.send_command("help")
-
+
# This should pass
tester.assert_response_contains("Thicket Feed Bot")
-
+
# This should fail
with pytest.raises(AssertionError):
-
tester.assert_response_contains("nonexistent text")
+
tester.assert_response_contains("nonexistent text")
+15 -16
tests/test_models.py
···
AtomEntry,
DuplicateMap,
FeedMetadata,
-
GitStoreIndex,
ThicketConfig,
UserConfig,
UserMetadata,
···
assert metadata.entry_count == original_count + 3
assert metadata.last_updated > original_time
-
+
def test_zulip_associations(self):
"""Test Zulip association methods."""
metadata = UserMetadata(
···
created=datetime.now(),
last_updated=datetime.now(),
)
-
+
# Test adding association
result = metadata.add_zulip_association("example.zulipchat.com", "alice")
assert result is True
assert len(metadata.zulip_associations) == 1
assert metadata.zulip_associations[0].server == "example.zulipchat.com"
assert metadata.zulip_associations[0].user_id == "alice"
-
+
# Test adding duplicate association
result = metadata.add_zulip_association("example.zulipchat.com", "alice")
assert result is False
assert len(metadata.zulip_associations) == 1
-
+
# Test adding different association
result = metadata.add_zulip_association("other.zulipchat.com", "alice")
assert result is True
assert len(metadata.zulip_associations) == 2
-
+
# Test get_zulip_mention
mention = metadata.get_zulip_mention("example.zulipchat.com")
assert mention == "alice"
-
+
mention = metadata.get_zulip_mention("other.zulipchat.com")
assert mention == "alice"
-
+
mention = metadata.get_zulip_mention("nonexistent.zulipchat.com")
assert mention is None
-
+
# Test removing association
result = metadata.remove_zulip_association("example.zulipchat.com", "alice")
assert result is True
assert len(metadata.zulip_associations) == 1
-
+
# Test removing non-existent association
result = metadata.remove_zulip_association("example.zulipchat.com", "alice")
assert result is False
···
class TestZulipAssociation:
"""Test ZulipAssociation model."""
-
+
def test_valid_association(self):
"""Test creating valid Zulip association."""
assoc = ZulipAssociation(
server="example.zulipchat.com",
user_id="alice@example.com"
)
-
+
assert assoc.server == "example.zulipchat.com"
assert assoc.user_id == "alice@example.com"
-
+
def test_association_hash(self):
"""Test that associations are hashable."""
assoc1 = ZulipAssociation(
···
server="other.zulipchat.com",
user_id="alice"
)
-
+
# Same associations should have same hash
assert hash(assoc1) == hash(assoc2)
-
+
# Different associations should have different hash
assert hash(assoc1) != hash(assoc3)
-
+
# Can be used in sets
assoc_set = {assoc1, assoc2, assoc3}
assert len(assoc_set) == 2 # assoc1 and assoc2 are considered the same