···
# When run directly by zulip-bots, add the package to path
src_dir = Path(__file__).parent.parent.parent
if str(src_dir) not in sys.path:
sys.path.insert(0, str(src_dir))
···
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)
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)
-
self._handle_claim_command(message, bot_handler, command_parts[1:], sender)
-
bot_handler.send_reply(message, f"Unknown command: {command}. Type `@mention help` for usage.")
self.logger.error(f"Error handling command '{command}': {e}")
bot_handler.send_reply(message, f"Error processing command: {str(e)}")
···
# 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()
-
return f"@{bot_name}" in content.lower() or f"@**{bot_name}**" in content.lower()
self.logger.debug(f"Could not get bot profile: {e}")
···
# 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', '')
# Remove @bot_name or @**bot_name**
escaped_name = re.escape(bot_name)
-
content = re.sub(rf'@(?:\*\*)?{escaped_name}(?:\*\*)?', '', content, flags=re.IGNORECASE).strip()
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()
def _send_help(self, message: dict[str, Any], bot_handler: BotHandler) -> None:
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."""
f"**Thicket Bot Status** (requested by {sender})",
···
-
"🐛 **Debug Mode:** ENABLED",
-
f"🎯 **Debug User:** {self.debug_user}",
-
f"📍 **Stream:** {self.stream_name or 'Not configured'}",
-
f"📝 **Topic:** {self.topic_name or 'Not configured'}",
-
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'}",
-
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:
"""Handle immediate sync request."""
if not self._check_initialization(message, bot_handler):
-
bot_handler.send_reply(message, f"🔄 Starting immediate sync... (requested by {sender})")
new_entries = self._perform_sync(bot_handler)
-
f"✅ Sync completed! Found {len(new_entries)} new entries."
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."""
self.posted_entries.clear()
self._save_posted_entries(bot_handler)
-
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}")
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()
-
f"**Thicket Bot Schedule** (requested by {sender})\n\n{schedule_info}"
def _handle_claim_command(
···
"""Handle username claiming command."""
···
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.")
···
server_url = zulip_site_url.replace("https://", "").replace("http://", "")
-
bot_handler.send_reply(message, "❌ Could not determine Zulip server URL.")
# Check if username exists in thicket
···
-
f"❌ Username `{username}` not found in thicket. Available users: {', '.join(self.git_store.list_users())}"
···
existing_zulip_id = user.get_zulip_mention(server_url)
# Check if it's claimed by the same user
-
if existing_zulip_id == sender_email or str(existing_zulip_id) == str(sender_user_id):
-
f"✅ Username `{username}` is already claimed by you on {server_url}!"
-
f"❌ Username `{username}` is already claimed by another user on {server_url}."
# Claim the username - prefer email for consistency
-
success = self.git_store.add_zulip_association(username, server_url, sender_email)
-
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):
notification_msg = f"👋 **{sender}** claimed thicket username `{username}` on {server_url}"
-
bot_handler.send_message({
-
"to": self.stream_name,
-
"subject": self.topic_name,
-
"content": notification_msg
-
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}")
-
f"❌ Failed to claim username `{username}`. This shouldn't happen - please contact an administrator."
···
"""Handle configuration commands."""
-
bot_handler.send_reply(message, "Usage: `@mention config <setting> <value>`")
setting = args[0].lower()
···
old_value = self.stream_name
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)
old_value = self.topic_name
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":
-
bot_handler.send_reply(message, "❌ Interval must be at least 60 seconds")
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")
-
bot_handler.send_reply(message, "❌ Invalid interval value. Must be a number of seconds.")
elif setting == "max_entries":
if max_entries < 1 or max_entries > 50:
-
bot_handler.send_reply(message, "❌ Max entries must be between 1 and 50")
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))
-
bot_handler.send_reply(message, "❌ Invalid max entries value. Must be a number.")
-
f"❌ Unknown setting: {setting}. Available: stream, topic, interval, max_entries"
def _load_bot_config(self, bot_handler: BotHandler) -> None:
···
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)
···
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}")
···
# Load thicket configuration
with open(self.config_path) as f:
config_data = yaml.safe_load(f)
self.config = ThicketConfig(**config_data)
···
zulip_user_id = user.get_zulip_mention(server_url)
-
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
···
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}")
# 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():
···
# First try the get_user_by_email API if available
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')
-
self.logger.info(f"Found user ID {user_id} for '{email_or_id}' via get_user_by_email API")
except (AttributeError, Exception):
# 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')
-
self.logger.error(f"No user found with identifier '{email_or_id}'. Searched {len(users_result['members'])} users.")
-
self.logger.error(f"Failed to get users: {users_result.get('msg', 'Unknown error')}")
self.logger.error(f"Error looking up user ID for '{email_or_id}': {e}")
-
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():
···
# Try get_user_by_email API first
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', '')
return str(user_id), full_name
···
# 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', '')
···
def _save_posted_entries(self, bot_handler: BotHandler) -> None:
"""Save the set of posted entries."""
-
bot_handler.storage.put("posted_entries", json.dumps(list(self.posted_entries)))
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:
-
"❌ Thicket not initialized. Please check configuration."
···
if not self.stream_name or not self.topic_name:
-
"❌ Stream and topic must be configured first. Use `@mention config stream <name>` and `@mention config topic <name>`"
···
def _schedule_sync(self, bot_handler: BotHandler) -> None:
"""Schedule periodic sync operations."""
-
can_sync = (self.git_store and
-
((self.stream_name and self.topic_name) or
self._perform_sync(bot_handler)
···
# Start background thread
sync_thread = threading.Thread(target=sync_loop, daemon=True)
···
asyncio.set_event_loop(loop)
new_count, _ = loop.run_until_complete(
-
sync_feed(self.git_store, username, str(feed_url), dry_run=False)
# 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
# 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}"
···
-
self.logger.error(f"Error syncing feed {feed_url} for user {username}: {e}")
if len(new_entries) >= self.max_entries_per_sync:
···
# 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)
···
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."""
# Get current Zulip server from environment
···
zulip_user_id = user.get_zulip_mention(server_url)
# 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
···
-
published_info = f" • {entry.published.strftime('%Y-%m-%d')}"
mention_info = f"@**{display_name}** posted{author_info}{published_info}:\n\n"
···
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
···
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)
user_id_to_use = resolved_id
-
self.logger.debug(f"Resolved {self.debug_zulip_user_id} to user ID {user_id_to_use}")
-
self.logger.error(f"Could not resolve user ID for {self.debug_zulip_user_id}")
# For private messages, user_id needs to be an integer, not string
user_id_int = int(user_id_to_use)
-
bot_handler.send_message({
-
"to": [user_id_int], # Use integer user ID
-
"content": debug_message
# If conversion to int fails, user_id_to_use might be an email
-
bot_handler.send_message({
-
"to": [user_id_to_use], # Try as string (email)
-
"content": debug_message
-
self.logger.error(f"Failed to send DM to {self.debug_user} (tried both int and string): {e2}")
-
self.logger.error(f"Failed to send DM to {self.debug_user} ({user_id_to_use}): {e}")
-
self.logger.info(f"Posted entry to debug user {self.debug_user}: {entry.title}")
# Normal mode: send to stream/topic
-
bot_handler.send_message({
-
"to": self.stream_name,
-
"subject": self.topic_name,
-
"content": message_content
-
self.logger.info(f"Posted entry to stream: {entry.title} (user: {username})")
self.logger.error(f"Error posting entry to Zulip: {e}")
···
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']
# Post-process to convert headings to bold for compact summaries
# 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)
# Fallback: manual HTML processing
# 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
self.logger.error(f"Error processing HTML content: {e}")
# Last resort: just strip HTML tags
-
return re.sub(r'<[^>]+>', '', html_content).strip()
def _get_schedule_info(self) -> str:
"""Get schedule information string."""
···
last_sync = datetime.datetime.fromtimestamp(self.last_sync_time)
next_sync = last_sync + datetime.timedelta(seconds=self.sync_interval)
now = datetime.datetime.now()
···
-
f"🕐 **Last Sync:** {last_sync.strftime('%H:%M:%S')}",
-
f"⏰ **Next Sync:** {next_sync.strftime('%H:%M:%S')} (in {time_str})",
-
f"🕐 **Last Sync:** {last_sync.strftime('%H:%M:%S')}",
-
f"⏰ **Next Sync:** Due now (running every {self.sync_interval}s)",
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"
···
-
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:
···
old_display = old_value if old_value else "(not set)"
-
notification_msg = f"⚙️ **{changer}** changed {setting}: `{old_display}` → `{new_value}`"
-
bot_handler.send_message({
-
"to": self.stream_name,
-
"subject": self.topic_name,
-
"content": notification_msg
self.logger.error(f"Failed to send config change notification: {e}")
handler_class = ThicketBotHandler
···
# When run directly by zulip-bots, add the package to path
src_dir = Path(__file__).parent.parent.parent
if str(src_dir) not in sys.path:
sys.path.insert(0, str(src_dir))
···
self._send_help(message, bot_handler)
elif command == "status":
self._send_status(message, bot_handler, sender)
+
and len(command_parts) > 1
+
and command_parts[1] == "now"
self._handle_force_sync(message, bot_handler, sender)
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)
+
self._handle_claim_command(
+
message, bot_handler, command_parts[1:], sender
+
bot_handler.send_reply(
+
f"Unknown command: {command}. Type `@mention help` for usage.",
self.logger.error(f"Error handling command '{command}': {e}")
bot_handler.send_reply(message, f"Error processing command: {str(e)}")
···
# 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()
+
f"@{bot_name}" in content.lower()
+
or f"@**{bot_name}**" in content.lower()
self.logger.debug(f"Could not get bot profile: {e}")
···
# 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", "")
# Remove @bot_name or @**bot_name**
escaped_name = re.escape(bot_name)
+
rf"@(?:\*\*)?{escaped_name}(?:\*\*)?",
self.logger.debug(f"Could not get bot profile for mention cleaning: {e}")
# Fallback to removing @thicket
+
r"@(?:\*\*)?thicket(?:\*\*)?", "", content, flags=re.IGNORECASE
def _send_help(self, message: dict[str, Any], bot_handler: BotHandler) -> None:
bot_handler.send_reply(message, self.usage())
+
self, message: dict[str, Any], bot_handler: BotHandler, sender: str
"""Send bot status information."""
f"**Thicket Bot Status** (requested by {sender})",
···
+
"🐛 **Debug Mode:** ENABLED",
+
f"🎯 **Debug User:** {self.debug_user}",
+
f"📍 **Stream:** {self.stream_name or 'Not configured'}",
+
f"📝 **Topic:** {self.topic_name or 'Not configured'}",
+
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'}",
+
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
"""Handle immediate sync request."""
if not self._check_initialization(message, bot_handler):
+
bot_handler.send_reply(
+
message, f"🔄 Starting immediate sync... (requested by {sender})"
new_entries = self._perform_sync(bot_handler)
+
message, f"✅ Sync completed! Found {len(new_entries)} new entries."
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
"""Handle reset command to clear posted entries tracking."""
self.posted_entries.clear()
self._save_posted_entries(bot_handler)
+
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}")
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
"""Handle schedule query command."""
schedule_info = self._get_schedule_info()
+
f"**Thicket Bot Schedule** (requested by {sender})\n\n{schedule_info}",
def _handle_claim_command(
···
"""Handle username claiming command."""
···
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."
···
server_url = zulip_site_url.replace("https://", "").replace("http://", "")
+
bot_handler.send_reply(
+
message, "❌ Could not determine Zulip server URL."
# Check if username exists in thicket
···
+
f"❌ Username `{username}` not found in thicket. Available users: {', '.join(self.git_store.list_users())}",
···
existing_zulip_id = user.get_zulip_mention(server_url)
# Check if it's claimed by the same user
+
if existing_zulip_id == sender_email or str(existing_zulip_id) == str(
+
f"✅ Username `{username}` is already claimed by you on {server_url}!",
+
f"❌ Username `{username}` is already claimed by another user on {server_url}.",
# Claim the username - prefer email for consistency
+
success = self.git_store.add_zulip_association(
+
username, server_url, sender_email
+
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
+
self.username_claim_notifications
+
and not self.debug_user
notification_msg = f"👋 **{sender}** claimed thicket username `{username}` on {server_url}"
+
bot_handler.send_message(
+
"to": self.stream_name,
+
"subject": self.topic_name,
+
"content": notification_msg,
+
f"Failed to send username claim notification: {e}"
+
f"User {sender} ({sender_email}) claimed username {username} on {server_url}"
+
f"❌ Failed to claim username `{username}`. This shouldn't happen - please contact an administrator.",
···
"""Handle configuration commands."""
+
bot_handler.send_reply(
+
message, "Usage: `@mention config <setting> <value>`"
setting = args[0].lower()
···
old_value = self.stream_name
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
old_value = self.topic_name
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":
+
bot_handler.send_reply(
+
message, "❌ Interval must be at least 60 seconds"
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.send_reply(
+
message, "❌ Invalid interval value. Must be a number of seconds."
elif setting == "max_entries":
if max_entries < 1 or max_entries > 50:
+
bot_handler.send_reply(
+
message, "❌ Max entries must be between 1 and 50"
old_value = self.max_entries_per_sync
self.max_entries_per_sync = max_entries
self._save_bot_config(bot_handler)
+
bot_handler.send_reply(
+
f"✅ Max entries per sync set to: **{max_entries}** (by {sender})",
+
self._send_config_change_notification(
+
"max entries per sync",
+
bot_handler.send_reply(
+
message, "❌ Invalid max entries value. Must be a number."
+
f"❌ Unknown setting: {setting}. Available: stream, topic, interval, max_entries",
def _load_bot_config(self, bot_handler: BotHandler) -> None:
···
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)
···
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}")
···
# Load thicket configuration
with open(self.config_path) as f:
config_data = yaml.safe_load(f)
self.config = ThicketConfig(**config_data)
···
zulip_user_id = user.get_zulip_mention(server_url)
+
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
···
if actual_user_id and actual_user_id != zulip_user_id:
# Successfully resolved to numeric ID
self.debug_zulip_user_id = actual_user_id
+
f"Debug mode enabled: Will send DMs to {self.debug_user} (email: {zulip_user_id}, user_id: {actual_user_id}) on {server_url}"
# Keep the email address, will resolve later when sending
self.debug_zulip_user_id = zulip_user_id
+
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
"""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():
···
# First try the get_user_by_email API if available
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")
+
f"Found user ID {user_id} for '{email_or_id}' via get_user_by_email API"
except (AttributeError, Exception):
# 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", "")
+
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")
+
f"No user found with identifier '{email_or_id}'. Searched {len(users_result['members'])} users."
+
f"Failed to get users: {users_result.get('msg', 'Unknown error')}"
self.logger.error(f"Error looking up user ID for '{email_or_id}': {e}")
+
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():
···
# Try get_user_by_email API first
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", "")
return str(user_id), full_name
···
# Fallback: search all users
users_result = client.get_users()
+
if users_result.get("result") == "success":
+
for user in users_result["members"]:
+
user.get("email") == email_or_id
+
or user.get("delivery_email") == email_or_id
+
return str(user.get("user_id")), user.get("full_name", "")
···
def _save_posted_entries(self, bot_handler: BotHandler) -> None:
"""Save the set of posted entries."""
+
bot_handler.storage.put(
+
"posted_entries", json.dumps(list(self.posted_entries))
self.logger.error(f"Error saving posted entries: {e}")
+
def _check_initialization(
+
self, message: dict[str, Any], bot_handler: BotHandler
"""Check if thicket is properly initialized."""
if not self.git_store or not self.config:
+
message, "❌ Thicket not initialized. Please check configuration."
···
if not self.stream_name or not self.topic_name:
+
"❌ Stream and topic must be configured first. Use `@mention config stream <name>` and `@mention config topic <name>`",
···
def _schedule_sync(self, bot_handler: BotHandler) -> None:
"""Schedule periodic sync operations."""
+
can_sync = self.git_store and (
+
(self.stream_name and self.topic_name) or self.debug_user
self._perform_sync(bot_handler)
···
# Start background thread
sync_thread = threading.Thread(target=sync_loop, daemon=True)
···
asyncio.set_event_loop(loop)
new_count, _ = loop.run_until_complete(
+
self.git_store, username, str(feed_url), dry_run=False
# 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
# Catchup mode: get configured number of entries on first run
+
catchup_entries = self.git_store.list_entries(
+
username, limit=self.catchup_entries
+
if not entries_to_check
for entry in entries_to_check:
entry_key = f"{username}:{entry.id}"
···
+
f"Error syncing feed {feed_url} for user {username}: {e}"
if len(new_entries) >= self.max_entries_per_sync:
···
# Rate limiting: pause after configured number of messages
+
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)
···
return [entry for entry, _ in new_entries]
+
def _post_entry_to_zulip(
+
self, entry: AtomEntry, bot_handler: BotHandler, username: str
"""Post a single entry to the configured Zulip stream/topic or debug user DM."""
# Get current Zulip server from environment
···
zulip_user_id = user.get_zulip_mention(server_url)
# 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
···
+
f" • {entry.published.strftime('%Y-%m-%d')}"
mention_info = f"@**{display_name}** posted{author_info}{published_info}:\n\n"
···
published_info = f" • {entry.published.strftime('%Y-%m-%d')}"
+
f"**{display_name}** posted{author_info}{published_info}:\n\n"
# Format the message with HTML processing
···
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
user_id_to_use = resolved_id
+
f"Resolved {self.debug_zulip_user_id} to user ID {user_id_to_use}"
+
f"Could not resolve user ID for {self.debug_zulip_user_id}"
# For private messages, user_id needs to be an integer, not string
user_id_int = int(user_id_to_use)
+
bot_handler.send_message(
+
"to": [user_id_int], # Use integer user ID
+
"content": debug_message,
# If conversion to int fails, user_id_to_use might be an email
+
bot_handler.send_message(
+
"to": [user_id_to_use], # Try as string (email)
+
"content": debug_message,
+
f"Failed to send DM to {self.debug_user} (tried both int and string): {e2}"
+
f"Failed to send DM to {self.debug_user} ({user_id_to_use}): {e}"
+
f"Posted entry to debug user {self.debug_user}: {entry.title}"
# Normal mode: send to stream/topic
+
bot_handler.send_message(
+
"to": self.stream_name,
+
"subject": self.topic_name,
+
"content": message_content,
+
f"Posted entry to stream: {entry.title} (user: {username})"
self.logger.error(f"Error posting entry to Zulip: {e}")
···
heading_style="ATX", # Use # for headings (but we'll post-process these)
bullets="-", # Use - for bullets
# Post-process to convert headings to bold for compact summaries
# Convert markdown headers to bold with period
+
r"^#{1,6}\s*(.+)$", r"**\1.**", markdown, flags=re.MULTILINE
# Clean up excessive newlines and make more compact
+
r"\n\s*\n\s*\n+", " ", markdown
+
) # Multiple newlines become space
+
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)
# Fallback: manual HTML processing
# Convert headings to bold with periods for compact summaries
+
r"<h[1-6](?:\s[^>]*)?>([^<]*)</h[1-6]>",
# Convert common HTML elements to Markdown
+
r"<(?:strong|b)(?:\s[^>]*)?>([^<]*)</(?:strong|b)>",
+
r"<(?:em|i)(?:\s[^>]*)?>([^<]*)</(?:em|i)>",
+
r"<code(?:\s[^>]*)?>([^<]*)</code>",
+
r'<a(?:\s[^>]*?)?\s*href=["\']([^"\']*)["\'](?:\s[^>]*)?>([^<]*)</a>',
# 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)
+
r"</?(?:p|div)(?:\s[^>]*)?>", " ", content, flags=re.IGNORECASE
# Remove remaining HTML tags
+
content = re.sub(r"<[^>]+>", "", content)
# Clean up whitespace and make compact
+
) # Multiple whitespace becomes single space
+
) # Multiple periods become single period
self.logger.error(f"Error processing HTML content: {e}")
# Last resort: just strip HTML tags
+
return re.sub(r"<[^>]+>", "", html_content).strip()
def _get_schedule_info(self) -> str:
"""Get schedule information string."""
···
last_sync = datetime.datetime.fromtimestamp(self.last_sync_time)
next_sync = last_sync + datetime.timedelta(seconds=self.sync_interval)
now = datetime.datetime.now()
···
+
f"🕐 **Last Sync:** {last_sync.strftime('%H:%M:%S')}",
+
f"⏰ **Next Sync:** {next_sync.strftime('%H:%M:%S')} (in {time_str})",
+
f"🕐 **Last Sync:** {last_sync.strftime('%H:%M:%S')}",
+
f"⏰ **Next Sync:** Due now (running every {self.sync_interval}s)",
lines.append("🕐 **Last Sync:** Never (bot starting up)")
# Add sync frequency info
if self.sync_interval >= 3600:
+
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"
···
+
def _send_config_change_notification(
+
bot_handler: BotHandler,
+
old_value: Optional[str],
"""Send configuration change notification if enabled."""
if not self.config_change_notifications or self.debug_user:
···
old_display = old_value if old_value else "(not set)"
+
f"⚙️ **{changer}** changed {setting}: `{old_display}` → `{new_value}`"
+
bot_handler.send_message(
+
"to": self.stream_name,
+
"subject": self.topic_name,
+
"content": notification_msg,
self.logger.error(f"Failed to send config change notification: {e}")
handler_class = ThicketBotHandler