Manage Atom feeds in a persistent git repository

Add comprehensive Zulip bot integration with dynamic features

- Complete Zulip bot implementation with debug mode and stream posting
- Dynamic bot name detection instead of hardcoded "@thicket"
- HTML to Markdown conversion with compact summary formatting
- Zulip user association management (add/remove/list commands)
- Rate limiting (5 messages per batch with 5s pause)
- Catchup mode (posts last 5 entries on first run)
- Smart duplicate author detection to avoid redundancy
- Comprehensive error handling and fallback mechanisms
- Bot configuration via chat commands (stream, topic, interval)
- Clean, non-redundant message formatting for feed updates

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

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

Changed files
+2182
bot-config
src
tests
+400
SPEC.md
···
···
+
# Thicket Git Store Specification
+
+
This document comprehensively defines the JSON format and structure of the Thicket Git repository, enabling third-party clients to read and write to the store while leveraging Thicket's existing Python classes for data validation and business logic.
+
+
## Overview
+
+
The Thicket Git store is a structured repository that persists Atom/RSS feed entries in JSON format. The store is designed to be both human-readable and machine-parseable, with a clear directory structure and standardized JSON schemas.
+
+
## Repository Structure
+
+
```
+
<git_store>/
+
├── index.json # Main index of all users and metadata
+
├── duplicates.json # Maps duplicate entry IDs to canonical IDs
+
├── index.opml # OPML export of all feeds (generated)
+
├── <username1>/ # User directory (sanitized username)
+
│ ├── <entry_id1>.json # Individual feed entry
+
│ ├── <entry_id2>.json # Individual feed entry
+
│ └── ...
+
├── <username2>/
+
│ ├── <entry_id3>.json
+
│ └── ...
+
└── ...
+
```
+
+
## JSON Schemas
+
+
### 1. Index File (`index.json`)
+
+
The main index tracks all users, their metadata, and repository statistics.
+
+
**Schema:**
+
```json
+
{
+
"users": {
+
"<username>": {
+
"username": "string",
+
"display_name": "string | null",
+
"email": "string | null",
+
"homepage": "string (URL) | null",
+
"icon": "string (URL) | null",
+
"feeds": ["string (URL)", ...],
+
"zulip_associations": [
+
{
+
"server": "string",
+
"user_id": "string"
+
},
+
...
+
],
+
"directory": "string",
+
"created": "string (ISO 8601 datetime)",
+
"last_updated": "string (ISO 8601 datetime)",
+
"entry_count": "integer"
+
}
+
},
+
"created": "string (ISO 8601 datetime)",
+
"last_updated": "string (ISO 8601 datetime)",
+
"total_entries": "integer"
+
}
+
```
+
+
**Example:**
+
```json
+
{
+
"users": {
+
"johndoe": {
+
"username": "johndoe",
+
"display_name": "John Doe",
+
"email": "john@example.com",
+
"homepage": "https://johndoe.blog",
+
"icon": "https://johndoe.blog/avatar.png",
+
"feeds": [
+
"https://johndoe.blog/feed.xml",
+
"https://johndoe.blog/categories/tech/feed.xml"
+
],
+
"zulip_associations": [
+
{
+
"server": "myorg.zulipchat.com",
+
"user_id": "john.doe"
+
},
+
{
+
"server": "community.zulipchat.com",
+
"user_id": "johndoe@example.com"
+
}
+
],
+
"directory": "johndoe",
+
"created": "2024-01-15T10:30:00",
+
"last_updated": "2024-01-20T14:22:00",
+
"entry_count": 42
+
}
+
},
+
"created": "2024-01-15T10:30:00",
+
"last_updated": "2024-01-20T14:22:00",
+
"total_entries": 42
+
}
+
```
+
+
### 2. Duplicates File (`duplicates.json`)
+
+
Maps duplicate entry IDs to their canonical representations to handle feed entries that appear with different IDs but identical content.
+
+
**Schema:**
+
```json
+
{
+
"duplicates": {
+
"<duplicate_id>": "<canonical_id>"
+
},
+
"comment": "Entry IDs that map to the same canonical content"
+
}
+
```
+
+
**Example:**
+
```json
+
{
+
"duplicates": {
+
"https://example.com/posts/123?utm_source=rss": "https://example.com/posts/123",
+
"https://example.com/feed/item-duplicate": "https://example.com/feed/item-original"
+
},
+
"comment": "Entry IDs that map to the same canonical content"
+
}
+
```
+
+
### 3. Feed Entry Files (`<username>/<entry_id>.json`)
+
+
Individual feed entries are stored as normalized Atom entries, regardless of their original format (RSS/Atom).
+
+
**Schema:**
+
```json
+
{
+
"id": "string",
+
"title": "string",
+
"link": "string (URL)",
+
"updated": "string (ISO 8601 datetime)",
+
"published": "string (ISO 8601 datetime) | null",
+
"summary": "string | null",
+
"content": "string | null",
+
"content_type": "html | text | xhtml",
+
"author": {
+
"name": "string | null",
+
"email": "string | null",
+
"uri": "string (URL) | null"
+
} | null,
+
"categories": ["string", ...],
+
"rights": "string | null",
+
"source": "string (URL) | null"
+
}
+
```
+
+
**Example:**
+
```json
+
{
+
"id": "https://johndoe.blog/posts/my-first-post",
+
"title": "My First Blog Post",
+
"link": "https://johndoe.blog/posts/my-first-post",
+
"updated": "2024-01-20T14:22:00",
+
"published": "2024-01-20T09:00:00",
+
"summary": "This is a summary of my first blog post.",
+
"content": "<p>This is the full content of my <strong>first</strong> blog post with HTML formatting.</p>",
+
"content_type": "html",
+
"author": {
+
"name": "John Doe",
+
"email": "john@example.com",
+
"uri": "https://johndoe.blog"
+
},
+
"categories": ["blogging", "personal"],
+
"rights": "Copyright 2024 John Doe",
+
"source": "https://johndoe.blog/feed.xml"
+
}
+
```
+
+
## Python Class Integration
+
+
To leverage Thicket's existing validation and business logic, third-party clients should use the following Python classes from the `thicket.models` package:
+
+
### Core Data Models
+
+
```python
+
from thicket.models import (
+
AtomEntry, # Feed entry representation
+
GitStoreIndex, # Repository index
+
UserMetadata, # User information
+
DuplicateMap, # Duplicate ID mappings
+
FeedMetadata, # Feed-level metadata
+
ThicketConfig, # Configuration
+
UserConfig, # User configuration
+
ZulipAssociation # Zulip server/user_id pairs
+
)
+
```
+
+
### Repository Operations
+
+
```python
+
from thicket.core.git_store import GitStore
+
from thicket.core.feed_parser import FeedParser
+
+
# Initialize git store
+
store = GitStore(Path("/path/to/git/store"))
+
+
# Read data
+
index = store._load_index() # Load index.json
+
user = store.get_user("username") # Get user metadata
+
entries = store.list_entries("username", limit=10)
+
entry = store.get_entry("username", "entry_id")
+
duplicates = store.get_duplicates() # Load duplicates.json
+
+
# Write data
+
store.add_user("username", display_name="Display Name")
+
store.store_entry("username", atom_entry)
+
store.add_duplicate("duplicate_id", "canonical_id")
+
store.commit_changes("Commit message")
+
+
# Zulip associations
+
store.add_zulip_association("username", "myorg.zulipchat.com", "user@example.com")
+
store.remove_zulip_association("username", "myorg.zulipchat.com", "user@example.com")
+
associations = store.get_zulip_associations("username")
+
+
# Search and statistics
+
results = store.search_entries("query", username="optional")
+
stats = store.get_stats()
+
```
+
+
### Feed Processing
+
+
```python
+
from thicket.core.feed_parser import FeedParser
+
from pydantic import HttpUrl
+
+
parser = FeedParser()
+
+
# Fetch and parse feeds
+
content = await parser.fetch_feed(HttpUrl("https://example.com/feed.xml"))
+
feed_metadata, entries = parser.parse_feed(content, source_url)
+
+
# Entry ID sanitization for filenames
+
safe_filename = parser.sanitize_entry_id(entry.id)
+
```
+
+
## File Naming and ID Sanitization
+
+
Entry IDs from feeds are sanitized to create safe filenames using `FeedParser.sanitize_entry_id()`:
+
+
- URLs are parsed and the path component is used as the base
+
- Characters are limited to alphanumeric, hyphens, underscores, and periods
+
- Other characters are replaced with underscores
+
- Maximum length is 200 characters
+
- Empty results default to "entry"
+
+
**Examples:**
+
- `https://example.com/posts/my-post` → `posts_my-post.json`
+
- `https://blog.com/2024/01/title?utm=source` → `2024_01_title.json`
+
+
## Data Validation
+
+
All JSON data should be validated using Pydantic models before writing to the store:
+
+
```python
+
from thicket.models import AtomEntry
+
from pydantic import ValidationError
+
+
try:
+
entry = AtomEntry(**json_data)
+
# Data is valid, safe to store
+
store.store_entry(username, entry)
+
except ValidationError as e:
+
# Handle validation errors
+
print(f"Invalid entry data: {e}")
+
```
+
+
## Timestamps
+
+
All timestamps use ISO 8601 format in UTC:
+
- `created`: When the record was first created
+
- `last_updated`: When the record was last modified
+
- `updated`: When the feed entry was last updated (from feed)
+
- `published`: When the feed entry was originally published (from feed)
+
+
## Content Sanitization
+
+
HTML content in entries is sanitized using the `FeedParser._sanitize_html()` method to prevent XSS attacks. Allowed tags and attributes are strictly controlled.
+
+
**Allowed HTML tags:**
+
`a`, `abbr`, `acronym`, `b`, `blockquote`, `br`, `code`, `em`, `i`, `li`, `ol`, `p`, `pre`, `strong`, `ul`, `h1`-`h6`, `img`, `div`, `span`
+
+
**Allowed attributes:**
+
- `a`: `href`, `title`
+
- `img`: `src`, `alt`, `title`, `width`, `height`
+
- `blockquote`: `cite`
+
- `abbr`/`acronym`: `title`
+
+
## Error Handling and Robustness
+
+
The store is designed to be fault-tolerant:
+
+
- Invalid entries are skipped during processing with error logging
+
- Malformed JSON files are ignored in listings
+
- Missing files return `None` rather than raising exceptions
+
- Git operations are atomic where possible
+
+
## Example Usage
+
+
### Reading the Store
+
+
```python
+
from pathlib import Path
+
from thicket.core.git_store import GitStore
+
+
# Initialize
+
store = GitStore(Path("/path/to/thicket/store"))
+
+
# Get all users
+
index = store._load_index()
+
for username, user_metadata in index.users.items():
+
print(f"User: {user_metadata.display_name} ({username})")
+
print(f" Feeds: {user_metadata.feeds}")
+
print(f" Entries: {user_metadata.entry_count}")
+
+
# Get recent entries for a user
+
entries = store.list_entries("johndoe", limit=5)
+
for entry in entries:
+
print(f" - {entry.title} ({entry.updated})")
+
```
+
+
### Adding Data
+
+
```python
+
from thicket.models import AtomEntry
+
from datetime import datetime
+
from pydantic import HttpUrl
+
+
# Create entry
+
entry = AtomEntry(
+
id="https://example.com/new-post",
+
title="New Post",
+
link=HttpUrl("https://example.com/new-post"),
+
updated=datetime.now(),
+
content="<p>Post content</p>",
+
content_type="html"
+
)
+
+
# Store entry
+
store.store_entry("johndoe", entry)
+
store.commit_changes("Add new blog post")
+
```
+
+
## Zulip Integration
+
+
The Thicket Git store supports Zulip bot integration for automatic feed posting with user mentions.
+
+
### Zulip Associations
+
+
Users can be associated with their Zulip identities to enable @mentions:
+
+
```python
+
# UserMetadata includes zulip_associations field
+
user.zulip_associations = [
+
ZulipAssociation(server="myorg.zulipchat.com", user_id="alice"),
+
ZulipAssociation(server="other.zulipchat.com", user_id="alice@example.com")
+
]
+
+
# Methods for managing associations
+
user.add_zulip_association("myorg.zulipchat.com", "alice")
+
user.get_zulip_mention("myorg.zulipchat.com") # Returns "alice"
+
user.remove_zulip_association("myorg.zulipchat.com", "alice")
+
```
+
+
### CLI Management
+
+
```bash
+
# Add association
+
thicket zulip-add alice myorg.zulipchat.com alice@example.com
+
+
# Remove association
+
thicket zulip-remove alice myorg.zulipchat.com alice@example.com
+
+
# List associations
+
thicket zulip-list # All users
+
thicket zulip-list alice # Specific user
+
+
# Bulk import from CSV
+
thicket zulip-import associations.csv
+
```
+
+
### Bot Behavior
+
+
When the Thicket Zulip bot posts articles:
+
+
1. It checks for Zulip associations matching the current server
+
2. If found, adds @mention to the post: `@**alice** posted:`
+
3. The mentioned user receives a notification in Zulip
+
+
This enables automatic notifications when someone's blog post is shared.
+
+
## Versioning and Compatibility
+
+
This specification describes version 1.1 of the Thicket Git store format. Changes from 1.0:
+
- Added `zulip_associations` field to UserMetadata (backwards compatible - defaults to empty list)
+
+
Future versions will maintain backward compatibility where possible, with migration tools provided for breaking changes.
+
+
To check the store format version, examine the repository structure and JSON schemas. Stores created by Thicket 0.1.0+ follow this specification.
+30
bot-config/run-bot.sh
···
···
+
#!/bin/bash
+
+
# Script to run the Thicket Zulip bot
+
# Usage: ./run-bot.sh [config-file]
+
+
set -e
+
+
# Default configuration file
+
CONFIG_FILE="${1:-./zuliprc}"
+
+
# Check if config file exists
+
if [ ! -f "$CONFIG_FILE" ]; then
+
echo "Error: Configuration file '$CONFIG_FILE' not found."
+
echo "Please copy zuliprc.template to zuliprc and fill in your bot credentials."
+
exit 1
+
fi
+
+
# Check if we're in the right directory
+
if [ ! -f "pyproject.toml" ]; then
+
echo "Error: Please run this script from the thicket project root directory."
+
exit 1
+
fi
+
+
echo "Starting Thicket Zulip bot with config: $CONFIG_FILE"
+
echo "Bot will be available as @thicket in your Zulip chat."
+
echo "Type Ctrl+C to stop the bot."
+
echo ""
+
+
# Run the bot using zulip-run-bot
+
uv run zulip-run-bot src/thicket/bots/thicket_bot.py --config-file "$CONFIG_FILE"
+5
src/thicket/bots/__init__.py
···
···
+
"""Zulip bot integration for thicket."""
+
+
from .thicket_bot import ThicketBotHandler
+
+
__all__ = ["ThicketBotHandler"]
+7
src/thicket/bots/requirements.txt
···
···
+
# Requirements for Thicket Zulip bot
+
# These are already included in the main thicket package
+
pydantic>=2.11.0
+
GitPython>=3.1.40
+
feedparser>=6.0.11
+
httpx>=0.28.0
+
pyyaml>=6.0.0
+202
src/thicket/bots/test_bot.py
···
···
+
"""Test utilities for the Thicket Zulip bot."""
+
+
import json
+
from pathlib import Path
+
from typing import Any, Dict, Optional
+
from unittest.mock import Mock
+
+
from ..models import AtomEntry, ThicketConfig
+
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] = {}
+
self.sent_messages: list[Dict[str, Any]] = []
+
self.config_info = {
+
"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 = {
+
"type": "reply",
+
"to": message.get("sender_id"),
+
"content": content,
+
"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."""
+
return MockStorage(self.storage_data)
+
+
+
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,
+
sender: str = "Test User",
+
sender_id: int = 12345,
+
message_type: str = "stream"
+
) -> Dict[str, Any]:
+
"""Create a test message for bot testing."""
+
return {
+
"content": content,
+
"sender_full_name": sender,
+
"sender_id": sender_id,
+
"type": message_type,
+
"timestamp": 1642694400, # 2022-01-20 12:00:00 UTC
+
"stream_id": 1,
+
"subject": "test topic"
+
}
+
+
+
def create_test_entry(
+
entry_id: str = "test-entry-1",
+
title: str = "Test Article",
+
link: str = "https://example.com/test-article"
+
) -> AtomEntry:
+
"""Create a test AtomEntry for testing."""
+
from datetime import datetime
+
from pydantic import HttpUrl
+
+
return AtomEntry(
+
id=entry_id,
+
title=title,
+
link=HttpUrl(link),
+
updated=datetime(2024, 1, 20, 12, 0, 0),
+
published=datetime(2024, 1, 20, 10, 0, 0),
+
summary="This is a test article summary",
+
content="<p>This is test article content</p>",
+
author={"name": "Test Author", "email": "author@example.com"}
+
)
+
+
+
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",
+
topic: str = "test-topic"
+
) -> None:
+
"""Configure the bot for testing."""
+
# Set bot configuration
+
config_data = {
+
"stream_name": stream,
+
"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()
+
assert content is not None, "No response received"
+
assert text in content, f"Response does not contain '{text}': {content}"
+
+
+
# Example usage for testing
+
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!")
+754
src/thicket/bots/thicket_bot.py
···
···
+
"""Zulip bot for automatically posting thicket feed updates."""
+
+
import asyncio
+
import json
+
import logging
+
import os
+
import time
+
from datetime import datetime
+
from pathlib import Path
+
from typing import Any, Dict, List, Optional, Set, Tuple
+
+
from zulip_bots.lib import BotHandler
+
+
# Handle imports for both direct execution and package import
+
try:
+
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.core.git_store import GitStore
+
from thicket.models import AtomEntry, ThicketConfig
+
from thicket.cli.commands.sync import sync_feed
+
+
+
class ThicketBotHandler:
+
"""Zulip bot that monitors thicket feeds and posts new articles."""
+
+
def __init__(self) -> None:
+
"""Initialize the thicket bot."""
+
self.logger = logging.getLogger(__name__)
+
self.git_store: Optional[GitStore] = None
+
self.config: Optional[ThicketConfig] = None
+
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
+
+
# 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 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 interval <seconds>` - Set sync interval
+
- `@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 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:
+
"""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)
+
elif command == "status":
+
self._send_status(message, bot_handler, sender)
+
elif command == "sync" and len(command_parts) > 1 and command_parts[1] == "now":
+
self._handle_force_sync(message, bot_handler, sender)
+
elif command == "reset":
+
self._handle_reset_command(message, bot_handler, sender)
+
elif command == "config":
+
self._handle_config_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:
+
self.logger.error(f"Error handling command '{command}': {e}")
+
bot_handler.send_reply(message, f"Error processing command: {str(e)}")
+
+
def _is_mentioned(self, content: str, bot_handler: BotHandler) -> bool:
+
"""Check if the bot is mentioned in the message."""
+
try:
+
# Get bot's actual name from Zulip
+
bot_info = bot_handler._client.get_profile()
+
if bot_info.get('result') == 'success':
+
bot_name = bot_info.get('full_name', '').lower()
+
if bot_name:
+
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()
+
if bot_info.get('result') == 'success':
+
bot_name = bot_info.get('full_name', '')
+
if bot_name:
+
# Remove @bot_name or @**bot_name**
+
escaped_name = re.escape(bot_name)
+
content = re.sub(rf'@(?:\*\*)?{escaped_name}(?:\*\*)?', '', content, flags=re.IGNORECASE).strip()
+
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:
+
"""Send help message."""
+
bot_handler.send_reply(message, self.usage())
+
+
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",
+
f"🎯 **Debug User:** {self.debug_user}",
+
"",
+
])
+
else:
+
status_lines.extend([
+
f"📍 **Stream:** {self.stream_name or 'Not configured'}",
+
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"📁 **Config Path:** {self.config_path or 'Not configured'}",
+
"",
+
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'}",
+
])
+
+
bot_handler.send_reply(message, "\n".join(status_lines))
+
+
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,
+
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:
+
"""Handle reset command to clear posted entries tracking."""
+
try:
+
self.posted_entries.clear()
+
self._save_posted_entries(bot_handler)
+
bot_handler.send_reply(
+
message,
+
f"✅ Posting history reset! Recent entries will be posted on next sync. (requested by {sender})"
+
)
+
self.logger.info(f"Posted entries tracking reset by {sender}")
+
except Exception as e:
+
self.logger.error(f"Reset failed: {e}")
+
bot_handler.send_reply(message, f"❌ Reset failed: {str(e)}")
+
+
def _handle_config_command(
+
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":
+
self.stream_name = value
+
self._save_bot_config(bot_handler)
+
bot_handler.send_reply(message, f"✅ Stream set to: **{value}** (by {sender})")
+
+
elif setting == "topic":
+
self.topic_name = value
+
self._save_bot_config(bot_handler)
+
bot_handler.send_reply(message, f"✅ Topic set to: **{value}** (by {sender})")
+
+
elif setting == "interval":
+
try:
+
interval = int(value)
+
if interval < 60:
+
bot_handler.send_reply(message, "❌ Interval must be at least 60 seconds")
+
return
+
self.sync_interval = interval
+
self._save_bot_config(bot_handler)
+
bot_handler.send_reply(message, f"✅ Sync interval set to: **{interval}s** (by {sender})")
+
except ValueError:
+
bot_handler.send_reply(message, "❌ Invalid interval value. Must be a number of seconds.")
+
+
else:
+
bot_handler.send_reply(
+
message,
+
f"❌ Unknown setting: {setting}. Available: stream, topic, interval"
+
)
+
+
def _load_bot_config(self, bot_handler: BotHandler) -> None:
+
"""Load bot configuration from persistent storage."""
+
try:
+
config_data = bot_handler.storage.get("bot_config")
+
if config_data:
+
config = json.loads(config_data)
+
self.stream_name = config.get("stream_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:
+
# Bot config not found on first run is expected
+
pass
+
+
def _save_bot_config(self, bot_handler: BotHandler) -> None:
+
"""Save bot configuration to persistent storage."""
+
try:
+
config_data = {
+
"stream_name": self.stream_name,
+
"topic_name": self.topic_name,
+
"sync_interval": self.sync_interval,
+
"max_entries_per_sync": self.max_entries_per_sync,
+
}
+
bot_handler.storage.put("bot_config", json.dumps(config_data))
+
except Exception as e:
+
self.logger.error(f"Error saving bot config: {e}")
+
+
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 actual_user_id and actual_user_id != zulip_user_id:
+
# Successfully resolved to numeric ID
+
self.debug_zulip_user_id = actual_user_id
+
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}")
+
else:
+
# Keep the email address, will resolve later when sending
+
self.debug_zulip_user_id = zulip_user_id
+
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)")
+
+
def _lookup_zulip_user_id(self, bot_handler: BotHandler, email_or_id: str) -> Optional[str]:
+
"""Look up Zulip user ID from email address or return the ID if it's already numeric."""
+
# 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)
+
if user_result.get('result') == 'success':
+
user_data = user_result.get('user', {})
+
user_id = user_data.get('user_id')
+
if user_id:
+
self.logger.info(f"Found user ID {user_id} for '{email_or_id}' via get_user_by_email API")
+
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
+
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]]:
+
"""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)
+
if user_result.get('result') == 'success':
+
user_data = user_result.get('user', {})
+
user_id = user_data.get('user_id')
+
full_name = user_data.get('full_name', '')
+
if user_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
+
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
+
+
def _load_posted_entries(self, bot_handler: BotHandler) -> None:
+
"""Load the set of already posted entries."""
+
try:
+
posted_data = bot_handler.storage.get("posted_entries")
+
if posted_data:
+
self.posted_entries = set(json.loads(posted_data))
+
except Exception:
+
# Empty set on first run is expected
+
self.posted_entries = set()
+
+
def _save_posted_entries(self, bot_handler: BotHandler) -> None:
+
"""Save the set of posted entries."""
+
try:
+
bot_handler.storage.put("posted_entries", json.dumps(list(self.posted_entries)))
+
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:
+
"""Check if thicket is properly initialized."""
+
if not self.git_store or not self.config:
+
bot_handler.send_reply(
+
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:
+
"""Schedule periodic sync operations."""
+
def sync_loop():
+
while True:
+
try:
+
# Check if we can sync
+
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]:
+
"""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
+
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:
+
try:
+
# Run async sync function
+
loop = asyncio.new_event_loop()
+
asyncio.set_event_loop(loop)
+
try:
+
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)
+
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)
+
+
self._save_posted_entries(bot_handler)
+
+
return [entry for entry, _ in new_entries]
+
+
def _post_entry_to_zulip(self, entry: AtomEntry, bot_handler: BotHandler, username: str) -> None:
+
"""Post a single entry to the configured Zulip stream/topic or debug user DM."""
+
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://", "")
+
+
# Build author/date info consistently
+
mention_info = ""
+
if server_url and self.git_store:
+
user = self.git_store.get_user(username)
+
if user:
+
zulip_user_id = user.get_zulip_mention(server_url)
+
if zulip_user_id:
+
# 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():
+
# Need to look up the numeric ID
+
resolved_id = self._lookup_zulip_user_id(bot_handler, user_id_to_use)
+
if resolved_id:
+
user_id_to_use = resolved_id
+
self.logger.debug(f"Resolved {self.debug_zulip_user_id} to user ID {user_id_to_use}")
+
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)
+
bot_handler.send_message({
+
"type": "private",
+
"to": [user_id_int], # Use integer user ID
+
"content": debug_message
+
})
+
except ValueError:
+
# If conversion to int fails, user_id_to_use might be an email
+
try:
+
bot_handler.send_message({
+
"type": "private",
+
"to": [user_id_to_use], # Try as string (email)
+
"content": debug_message
+
})
+
except Exception as e2:
+
self.logger.error(f"Failed to send DM to {self.debug_user} (tried both int and string): {e2}")
+
return
+
except Exception as e:
+
self.logger.error(f"Failed to send DM to {self.debug_user} ({user_id_to_use}): {e}")
+
return
+
self.logger.info(f"Posted entry to debug user {self.debug_user}: {entry.title}")
+
else:
+
# Normal mode: send to stream/topic
+
bot_handler.send_message({
+
"type": "stream",
+
"to": self.stream_name,
+
"subject": self.topic_name,
+
"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}")
+
+
def _process_html_content(self, html_content: str) -> str:
+
"""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,
+
heading_style="ATX", # Use # for headings (but we'll post-process these)
+
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()
+
+
+
handler_class = ThicketBotHandler
+229
src/thicket/cli/commands/bot.py
···
···
+
"""Bot management commands for thicket."""
+
+
import subprocess
+
import sys
+
from pathlib import Path
+
+
import typer
+
from rich.console import Console
+
+
from ..main import app
+
from ..utils import print_error, print_info, print_success
+
+
console = Console()
+
+
+
@app.command()
+
def bot(
+
action: str = typer.Argument(..., help="Action: run, test, or status"),
+
config_file: Path = typer.Option(
+
Path("bot-config/zuliprc"),
+
"--config",
+
"-c",
+
help="Zulip bot configuration file",
+
),
+
thicket_config: Path = typer.Option(
+
Path("thicket.yaml"),
+
"--thicket-config",
+
help="Path to thicket configuration file",
+
),
+
daemon: bool = typer.Option(
+
False,
+
"--daemon",
+
"-d",
+
help="Run bot in daemon mode (background)",
+
),
+
debug_user: str = typer.Option(
+
None,
+
"--debug-user",
+
help="Debug mode: send DMs to this thicket username instead of posting to streams",
+
),
+
) -> None:
+
"""Manage the Thicket Zulip bot.
+
+
Actions:
+
- run: Start the Zulip bot
+
- test: Test bot functionality
+
- status: Show bot status
+
"""
+
+
if action == "run":
+
_run_bot(config_file, thicket_config, daemon, debug_user)
+
elif action == "test":
+
_test_bot()
+
elif action == "status":
+
_bot_status(config_file)
+
else:
+
print_error(f"Unknown action: {action}")
+
print_info("Available actions: run, test, status")
+
raise typer.Exit(1)
+
+
+
def _run_bot(config_file: Path, thicket_config: Path, daemon: bool, debug_user: str = None) -> None:
+
"""Run the Zulip bot."""
+
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")
+
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 = [
+
sys.executable, "-m", "zulip_bots.run",
+
"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(
+
cmd,
+
stdout=subprocess.DEVNULL,
+
stderr=subprocess.DEVNULL,
+
start_new_session=True,
+
env=env
+
)
+
print_success(f"Bot started in background with PID {process.pid}")
+
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)
+
except KeyboardInterrupt:
+
print_info("Bot stopped by user")
+
+
+
def _parse_zulip_config(config_file: Path) -> str:
+
"""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}")
+
return 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")
+
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)
+
+
+
def _bot_status(config_file: Path) -> None:
+
"""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")
+
+
# Check dependencies
+
try:
+
import zulip_bots
+
version = getattr(zulip_bots, '__version__', 'unknown')
+
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")
+258
src/thicket/cli/commands/zulip.py
···
···
+
"""Zulip association management commands for thicket."""
+
+
from pathlib import Path
+
from typing import Optional
+
+
import typer
+
from rich.console import Console
+
from rich.table import Table
+
+
from ...core.git_store import GitStore
+
from ..main import app
+
from ..utils import load_config, print_error, print_info, print_success
+
+
console = Console()
+
+
+
@app.command()
+
def zulip_add(
+
username: str = typer.Argument(..., help="Username to associate with Zulip"),
+
server: str = typer.Argument(..., help="Zulip server (e.g., yourorg.zulipchat.com)"),
+
user_id: str = typer.Argument(..., help="Zulip user ID or email for @mentions"),
+
config_file: Path = typer.Option(
+
Path("thicket.yaml"),
+
"--config",
+
"-c",
+
help="Path to thicket configuration file",
+
),
+
) -> None:
+
"""Add a Zulip association for a user.
+
+
This associates a thicket user with their Zulip identity, enabling
+
@mentions when the bot posts their articles.
+
+
Example:
+
thicket zulip-add alice myorg.zulipchat.com alice@example.com
+
"""
+
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)
+
+
+
@app.command()
+
def zulip_remove(
+
username: str = typer.Argument(..., help="Username to remove association from"),
+
server: str = typer.Argument(..., help="Zulip server"),
+
user_id: str = typer.Argument(..., help="Zulip user ID or email"),
+
config_file: Path = typer.Option(
+
Path("thicket.yaml"),
+
"--config",
+
"-c",
+
help="Path to thicket configuration file",
+
),
+
) -> None:
+
"""Remove a Zulip association from a user.
+
+
Example:
+
thicket zulip-remove alice myorg.zulipchat.com alice@example.com
+
"""
+
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}")
+
git_store.commit_changes(f"Remove Zulip association for {username}")
+
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)
+
+
+
@app.command()
+
def zulip_list(
+
username: Optional[str] = typer.Argument(None, help="Username to list associations for"),
+
config_file: Path = typer.Option(
+
Path("thicket.yaml"),
+
"--config",
+
"-c",
+
help="Path to thicket configuration file",
+
),
+
) -> None:
+
"""List Zulip associations for users.
+
+
If no username is provided, lists associations for all users.
+
+
Examples:
+
thicket zulip-list # List all associations
+
thicket zulip-list alice # List associations for alice
+
"""
+
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)
+
+
+
@app.command()
+
def zulip_import(
+
csv_file: Path = typer.Argument(..., help="CSV file with username,server,user_id"),
+
config_file: Path = typer.Option(
+
Path("thicket.yaml"),
+
"--config",
+
"-c",
+
help="Path to thicket configuration file",
+
),
+
dry_run: bool = typer.Option(
+
False,
+
"--dry-run",
+
help="Show what would be imported without making changes",
+
),
+
) -> None:
+
"""Import Zulip associations from a CSV file.
+
+
CSV format (no header):
+
username,server,user_id
+
alice,myorg.zulipchat.com,alice@example.com
+
bob,myorg.zulipchat.com,bob.smith
+
+
Example:
+
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:
+
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
+
for a in user.zulip_associations
+
)
+
if exists:
+
print_info(f"Would skip existing: {username} -> {user_id}@{server}")
+
skipped += 1
+
else:
+
print_info(f"Would add: {username} -> {user_id}@{server}")
+
added += 1
+
else:
+
# Actually add association
+
if git_store.add_zulip_association(username, server, user_id):
+
print_success(f"Added: {username} -> {user_id}@{server}")
+
added += 1
+
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(f" Would add: {added}")
+
else:
+
console.print(f"[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)
+297
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.thicket_bot import ThicketBotHandler
+
+
+
class TestThicketBot:
+
"""Test suite for ThicketBotHandler."""
+
+
def setup_method(self) -> None:
+
"""Set up test environment."""
+
self.bot = ThicketBotHandler()
+
self.handler = MockBotHandler()
+
+
def test_usage(self) -> None:
+
"""Test bot usage message."""
+
usage = self.bot.usage()
+
assert "Thicket Feed Bot" in usage
+
assert "@thicket status" in usage
+
assert "@thicket config" in usage
+
+
def test_help_command(self) -> None:
+
"""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
+
+
def test_status_command_unconfigured(self) -> None:
+
"""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
+
assert "Stream:" in response
+
assert "Topic:" in response
+
+
def test_config_stream_command(self) -> None:
+
"""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
+
assert self.bot.stream_name == "general"
+
+
def test_config_topic_command(self) -> None:
+
"""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
+
assert self.bot.topic_name == "'Feed Updates'"
+
+
def test_config_interval_command(self) -> None:
+
"""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
+
assert self.bot.sync_interval == 600
+
+
def test_config_interval_too_small(self) -> None:
+
"""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
+
assert self.bot.sync_interval != 30
+
+
def test_config_path_nonexistent(self) -> None:
+
"""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
+
+
def test_unknown_command(self) -> None:
+
"""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
+
+
def test_config_persistence(self) -> None:
+
"""Test that configuration is persisted."""
+
# Set some config
+
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"
+
assert new_bot.sync_interval == 600
+
+
def test_posted_entries_persistence(self) -> None:
+
"""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"}
+
+
def test_mention_detection(self) -> None:
+
"""Test bot mention detection."""
+
assert self.bot._is_mentioned("@Thicket Bot help", self.handler)
+
assert self.bot._is_mentioned("@thicket status", self.handler)
+
assert not self.bot._is_mentioned("regular message", self.handler)
+
+
def test_mention_cleaning(self) -> None:
+
"""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"
+
+
def test_sync_now_uninitialized(self) -> None:
+
"""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"]
+
assert "DEBUG:" in message["content"]
+
assert entry.title in message["content"]
+
assert "@**author.user** posted:" in message["content"]
+
+
+
class TestBotTester:
+
"""Test the bot testing utilities."""
+
+
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
+
assert "Thicket Feed Bot" in tester.get_last_response_content()
+
+
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")