diff --git a/config.ini.example b/config.ini.example
index 638ed83..88d0ba6 100644
--- a/config.ini.example
+++ b/config.ini.example
@@ -90,6 +90,12 @@ bot_longitude = -74.0060
# Set to a lower value if you want to limit channel fetching for performance
max_channels = 12
+# Channel refresh interval in seconds
+# How often to refresh channel list from device to prevent stale data in database
+# Default: 3600 (1 hour). Set to 0 to disable periodic refresh (channels only refreshed on startup)
+# This ensures the database stays in sync if channels are changed on the device directly
+channel_refresh_interval_seconds = 3600
+
# Interval-based advertising settings
# Send periodic flood adverts at specified intervals
# 0: Disabled (default)
@@ -254,73 +260,6 @@ colored_output = true
# Options: DEBUG, INFO, WARNING, ERROR, CRITICAL
meshcore_log_level = INFO
-[Greeter_Command]
-# Enable or disable the greeter command
-# true: Bot will greet users on their first public channel message
-# false: Greeter is disabled
-enabled = false
-
-# Channels where greetings should occur (comma-separated)
-# IMPORTANT: Leave commented out (or omit entirely) to use global monitor_channels (default behavior)
-# If uncommented with empty value (channels = ), command will be DM-only
-# Comma-separated list to restrict to specific channels (only greeter command works there)
-# Example: channels = general,welcome,newbies
-# If not specified, uses the channels from [Channels] monitor_channels setting
-# channels =
-
-# Greeting message template (default for all channels)
-# Available fields: {sender} - the user's name/ID
-# For multi-part greetings, separate messages with pipe (|)
-# Example (single): "Welcome to the mesh, @[{sender}]!"
-# Example (multi-part): "Welcome to the mesh, @[{sender}]!|This is a great place to chat.|Use !help for commands."
-greeting_message = Welcome to the mesh, @[{sender}]!
-
-# Channel-specific greeting messages (optional)
-# Format: channel_name:greeting_message,channel_name2:greeting_message2
-# If a channel has a specific greeting, it will be used instead of the default greeting_message
-# Example: Public:Welcome to Public channel, @[{sender}]!|general:Welcome to general, @[{sender}]!
-# Multi-part greetings are supported per channel using pipe (|) separator
-# Leave empty to use greeting_message for all channels
-channel_greetings =
-
-# Per-channel greetings (tracking behavior)
-# false: Greet each user only once globally (default - user gets one greeting total)
-# true: Greet each user once per channel (user can be greeted on each channel separately)
-# Note: This controls tracking, not the greeting message itself. Use channel_greetings for different messages.
-per_channel_greetings = false
-
-# Include mesh network information in greeting
-# true: Add mesh statistics to greeting (total contacts, repeaters, etc.)
-# false: Only send the greeting message
-include_mesh_info = true
-
-# Mesh info format template
-# Available fields: {total_contacts}, {repeaters}, {companions}, {recent_activity_24h}
-# Example: "\n\nMesh Info: {total_contacts} contacts, {repeaters} repeaters"
-# Note: Mesh info is appended to the last greeting message part
-mesh_info_format = \n\nMesh Info: {total_contacts} contacts, {repeaters} repeaters, {recent_activity_24h} active in last 24h
-
-# Rollout period in days
-# When greeter is first enabled on an active mesh, this sets how many days
-# to listen and mark all active users as already greeted before beginning
-# to greet new users. This prevents greeting everyone on an established mesh.
-# Set to 0 to disable rollout (will greet all new users immediately)
-# Note: Use auto_backfill to mark historical users and shorten/eliminate rollout period
-rollout_days = 7
-
-# Auto-backfill from historical message_stats data
-# true: Automatically mark all users who have posted on public channels in the past
-# false: Only mark users during rollout period (default)
-# This allows shortening or eliminating the rollout period by using existing data
-auto_backfill = false
-
-# Backfill lookback period in days
-# Number of days to look back when auto-backfilling (0 = all time)
-# Only used if auto_backfill = true
-# Example: 30 = only mark users who posted in last 30 days
-# Example: 0 = mark all users who have ever posted (all time)
-backfill_lookback_days = 30
-
[Custom_Syntax]
# Custom syntax patterns for special message formats
# Format: pattern = "response_format"
@@ -372,6 +311,55 @@ repeater_prefix_api_url =
# Recommended: 1-6 hours (data doesn't change frequently)
repeater_prefix_cache_hours = 1
+[Feed_Manager]
+# Enable feed manager functionality
+# true: Feed manager is enabled and will poll feeds
+# false: Feed manager is disabled
+feed_manager_enabled = false
+
+# Default check interval in seconds (5 minutes)
+# How often to check feeds for new items
+# Individual feeds can override this with their own interval
+default_check_interval_seconds = 300
+
+# Maximum number of items to process per feed per check
+# Prevents overwhelming channels with too many items at once
+max_items_per_check = 10
+
+# Default output format for feed messages
+# Placeholders: {title}, {body}, {date}, {link}, {emoji}
+# Shortening functions: {field|truncate:N}, {field|word_wrap:N}, {field|first_words:N}
+# Example: "{emoji} {body|truncate:100} - {date}\n{link|truncate:50}"
+# Default uses body instead of title for main content
+default_output_format = {emoji} {body|truncate:100} - {date}\n{link|truncate:50}
+
+# Default message send interval in seconds
+# How long to wait between sending queued messages from the same feed
+# Prevents rate limiting by spacing out message sends
+# Individual feeds can override this with their own interval
+default_message_send_interval_seconds = 2.0
+
+# Request timeout in seconds
+# Maximum time to wait for feed requests
+feed_request_timeout = 30
+
+# User-Agent string for HTTP requests
+# Identifies the bot when fetching feeds
+feed_user_agent = MeshCoreBot/1.0 FeedManager
+
+# Rate limiting: minimum seconds between requests to same domain
+# Prevents overwhelming feed sources
+feed_rate_limit_seconds = 5
+
+# Maximum message length (mesh limit is 130)
+# Messages longer than this will be truncated
+max_message_length = 130
+
+# Enable/disable feed command
+# true: Feed command is available (admin only)
+# false: Feed command is disabled
+feed_command_enabled = true
+
[Prefix_Command]
# Enable or disable repeater geolocation in prefix command
# true: Show city names with repeaters when location data is available
@@ -655,4 +643,95 @@ debug = false
# Auto-start web viewer with bot
# true: Start web viewer automatically when bot starts
# false: Start web viewer manually
-auto_start = false
\ No newline at end of file
+auto_start = false
+
+[Greeter_Command]
+# Enable or disable the greeter command
+# true: Bot will greet users on their first public channel message
+# false: Greeter is disabled
+enabled = false
+
+# Channels where greetings should occur (comma-separated)
+# IMPORTANT: Leave commented out (or omit entirely) to use global monitor_channels (default behavior)
+# If uncommented with empty value (channels = ), command will be DM-only
+# Comma-separated list to restrict to specific channels (only greeter command works there)
+# Example: channels = general,welcome,newbies
+# If not specified, uses the channels from [Channels] monitor_channels setting
+# channels =
+
+# Greeting message template (default for all channels)
+# Available fields: {sender} - the user's name/ID
+# For multi-part greetings, separate messages with pipe (|)
+# Example (single): "Welcome to the mesh, @[{sender}]!"
+# Example (multi-part): "Welcome to the mesh, @[{sender}]!|This is a great place to chat.|Use !help for commands."
+greeting_message = Welcome to the mesh, @[{sender}]!
+
+# Channel-specific greeting messages (optional)
+# Format: channel_name:greeting_message,channel_name2:greeting_message2
+# If a channel has a specific greeting, it will be used instead of the default greeting_message
+# Example: Public:Welcome to Public channel, @[{sender}]!|general:Welcome to general, @[{sender}]!
+# Multi-part greetings are supported per channel using pipe (|) separator
+# Leave empty to use greeting_message for all channels
+channel_greetings =
+
+# Per-channel greetings (tracking behavior)
+# false: Greet each user only once globally (default - user gets one greeting total)
+# true: Greet each user once per channel (user can be greeted on each channel separately)
+# Note: This controls tracking, not the greeting message itself. Use channel_greetings for different messages.
+per_channel_greetings = false
+
+# Include mesh network information in greeting
+# true: Add mesh statistics to greeting (total contacts, repeaters, etc.)
+# false: Only send the greeting message
+include_mesh_info = true
+
+# Mesh info format template
+# Available fields: {total_contacts}, {repeaters}, {companions}, {recent_activity_24h}
+# Example: "\n\nMesh Info: {total_contacts} contacts, {repeaters} repeaters"
+# Note: Mesh info is appended to the last greeting message part
+mesh_info_format = \n\nMesh Info: {total_contacts} contacts, {repeaters} repeaters, {recent_activity_24h} active in last 24h
+
+# Rollout period in days
+# When greeter is first enabled on an active mesh, this sets how many days
+# to listen and mark all active users as already greeted before beginning
+# to greet new users. This prevents greeting everyone on an established mesh.
+# Set to 0 to disable rollout (will greet all new users immediately)
+# Note: Use auto_backfill to mark historical users and shorten/eliminate rollout period
+rollout_days = 7
+
+# Auto-backfill from historical message_stats data
+# true: Automatically mark all users who have posted on public channels in the past
+# false: Only mark users during rollout period (default)
+# This allows shortening or eliminating the rollout period by using existing data
+auto_backfill = false
+
+# Backfill lookback period in days
+# Number of days to look back when auto-backfilling (0 = all time)
+# Only used if auto_backfill = true
+# Example: 30 = only mark users who posted in last 30 days
+# Example: 0 = mark all users who have ever posted (all time)
+backfill_lookback_days = 30
+
+# Dead air delay in seconds
+# Wait this many seconds before sending a greeting to a new user
+# This allows time for other users to greet the new user first
+# Set to 0 to send greetings immediately (default behavior)
+# Example: 30 = wait 30 seconds before greeting
+dead_air_delay_seconds = 0
+
+# Defer to human greeting
+# If enabled and dead_air_delay_seconds > 0, the bot will not send a greeting
+# if another user mentions the new user's name within the dead air delay period
+# true: Check for human greetings and defer if found
+# false: Always send bot greeting after delay (default)
+# Note: Only effective when dead_air_delay_seconds > 0
+defer_to_human_greeting = false
+
+# Levenshtein distance for name matching
+# Maximum edit distance allowed when checking if a user has been greeted before
+# Prevents duplicate greetings for users who make small changes to their name
+# Set to 0 to disable fuzzy matching (exact name match only, default)
+# Example: 2 = "John" and "Jon" (distance 1) or "John" and "Jhon" (distance 1) would match
+# Example: 3 = "Alice" and "Alicia" (distance 2) would match
+# Recommended: 1-3 for most use cases
+levenshtein_distance = 0
\ No newline at end of file
diff --git a/docs/FEEDS.md b/docs/FEEDS.md
new file mode 100644
index 0000000..4b7c12f
--- /dev/null
+++ b/docs/FEEDS.md
@@ -0,0 +1,413 @@
+# Feed Management
+
+The Feed Management system allows the bot to subscribe to RSS feeds and REST APIs, automatically polling for new content and posting updates to specified mesh channels.
+
+## Overview
+
+The feed manager supports two feed types:
+- **RSS Feeds**: Standard RSS/Atom feeds
+- **API Feeds**: REST API endpoints returning JSON data
+
+Both feed types support:
+- Configurable polling intervals
+- Custom message formatting
+- Item filtering
+- Sorting
+- Automatic deduplication
+- Rate limiting
+
+## Configuration
+
+### Global Settings
+
+Configure feed manager behavior in `config.ini`:
+
+```ini
+[Feed_Manager]
+# Enable/disable feed manager
+feed_manager_enabled = true
+
+# Default check interval (seconds)
+default_check_interval_seconds = 300
+
+# Maximum items to process per check
+max_items_per_check = 10
+
+# HTTP request timeout (seconds)
+feed_request_timeout = 30
+
+# User agent for HTTP requests
+feed_user_agent = MeshCoreBot/1.0 FeedManager
+
+# Rate limit between requests to same domain (seconds)
+feed_rate_limit_seconds = 5.0
+
+# Maximum message length (characters)
+max_message_length = 130
+
+# Default output format
+default_output_format = {emoji} {body|truncate:100} - {date}\n{link|truncate:50}
+
+# Default interval between sending queued messages (seconds)
+default_message_send_interval_seconds = 2.0
+```
+
+## RSS Feed Configuration
+
+The web interface provides separate input fields for each configuration option. Below are examples showing the values to enter in each field.
+
+### Basic RSS Feed
+
+**Feed Type:** `rss`
+**Feed URL:** `https://example.com/rss.xml`
+**Channel:** `#alerts`
+**Feed Name (Optional):** `Example RSS Feed`
+**Check Interval (seconds):** `300`
+**Output Format:** (leave empty to use default)
+**Message Send Interval (seconds):** `2.0`
+**Filter Configuration:** (leave empty)
+**Sort Configuration:** (leave empty)
+
+### RSS Feed with Custom Format
+
+**Feed Type:** `rss`
+**Feed URL:** `https://example.com/rss.xml`
+**Channel:** `#alerts`
+**Feed Name (Optional):** `Emergency Alerts`
+**Check Interval (seconds):** `60`
+**Output Format:**
+```
+{emoji} {title|truncate:80}
+{body|truncate:100}
+{date}
+```
+**Message Send Interval (seconds):** `2.0`
+**Filter Configuration:** (leave empty)
+**Sort Configuration:** (leave empty)
+
+## API Feed Configuration
+
+### Basic API Feed
+
+**Feed Type:** `api`
+**Feed URL:** `https://api.example.com/alerts`
+**Channel:** `#alerts`
+**Feed Name (Optional):** `API Alerts`
+**Check Interval (seconds):** `300`
+**Output Format:** (leave empty to use default)
+**Message Send Interval (seconds):** `2.0`
+**API Configuration (JSON):**
+```json
+{
+ "method": "GET",
+ "headers": {},
+ "params": {
+ "api_key": "your-api-key"
+ },
+ "response_parser": {
+ "items_path": "data.alerts",
+ "id_field": "id",
+ "title_field": "title",
+ "description_field": "description",
+ "timestamp_field": "created_at"
+ }
+}
+```
+**Filter Configuration:** (leave empty)
+**Sort Configuration:** (leave empty)
+
+### WSDOT Highway Alerts Example
+
+**Feed Type:** `api`
+**Feed URL:** `https://wsdot.wa.gov/Traffic/api/HighwayAlerts/HighwayAlertsREST.svc/GetAlertsAsJson`
+**Channel:** `#traffic`
+**Feed Name (Optional):** `WSDOT Highway Alerts`
+**Check Interval (seconds):** `300`
+**Output Format:**
+```
+{emoji} [{raw.Priority|switch:highest:đ´:high:đ :medium:đĄ:âĒ}] {title|truncate:80}
+{raw.EventCategory} | {raw.Region} | {raw.EventStatus}
+{body|truncate:70}
+```
+**Message Send Interval (seconds):** `2.0`
+**API Configuration (JSON):**
+```json
+{
+ "method": "GET",
+ "headers": {},
+ "params": {
+ "AccessCode": "your-access-code"
+ },
+ "response_parser": {
+ "items_path": "",
+ "id_field": "AlertID",
+ "title_field": "HeadlineDescription",
+ "description_field": "ExtendedDescription",
+ "timestamp_field": "LastUpdatedTime"
+ }
+}
+```
+**Filter Configuration (JSON):**
+```json
+{
+ "conditions": [
+ {
+ "field": "raw.EventCategory",
+ "operator": "in",
+ "values": ["Alert", "Closure"]
+ }
+ ],
+ "logic": "OR"
+}
+```
+**Sort Configuration (JSON):**
+```json
+{
+ "field": "raw.LastUpdatedTime",
+ "order": "desc"
+}
+```
+
+## Output Format
+
+The output format string controls how feed items are formatted before sending to channels.
+
+### Placeholders
+
+- `{title}` - Item title
+- `{body}` - Item description/body text
+- `{date}` - Relative time (e.g., "5m ago", "2h 30m ago")
+- `{link}` - Item URL
+- `{emoji}` - Auto-selected emoji based on feed name (đĸ, đ¨, â ī¸, âšī¸)
+- `{raw.field}` - Access raw API data fields (API feeds only)
+- `{raw.nested.field}` - Access nested API fields (e.g., `{raw.StartRoadwayLocation.RoadName}`)
+
+### Shortening Functions
+
+Apply functions to placeholders using the pipe operator:
+
+- `{field|truncate:N}` - Truncate to N characters
+- `{field|word_wrap:N}` - Wrap at N characters, breaking at word boundaries
+- `{field|first_words:N}` - Take first N words
+
+**Examples:**
+```
+{title|truncate:60}
+{body|word_wrap:100}
+{body|first_words:20}
+```
+
+### Regex Extraction
+
+Extract specific content using regex patterns:
+
+- `{field|regex:pattern}` - Extract using regex (uses first capture group)
+- `{field|regex:pattern:group}` - Extract specific capture group (0 = whole match, 1 = first group, etc.)
+
+**Examples:**
+```
+{body|regex:Temperature:\s*([^\n]+):1}
+{body|regex:Conditions:\s*([^\n]+):1}
+```
+
+### Conditional Formatting
+
+- `{field|if_regex:pattern:then:else}` - If pattern matches, return "then", else return "else"
+- `{field|switch:value1:result1:value2:result2:...:default}` - Multi-value conditional
+
+**Examples:**
+```
+{raw.Priority|switch:highest:đ´:high:đ :medium:đĄ:âĒ}
+{body|if_regex:No restrictions:đ:Restrictions apply}
+```
+
+### Extract and Check
+
+- `{field|regex_cond:extract_pattern:check_pattern:then:group}` - Extract text, check if it matches pattern, return "then" if match, else return extracted text
+
+**Example:**
+```
+{body|regex_cond:Northbound\s*\n([^\n]+):No restrictions:đ:1}
+```
+
+## Filter Configuration
+
+Filter configuration determines which items are sent to channels.
+
+### Filter Structure
+
+```json
+{
+ "conditions": [
+ {
+ "field": "raw.Priority",
+ "operator": "in",
+ "values": ["highest", "high"]
+ },
+ {
+ "field": "raw.EventStatus",
+ "operator": "equals",
+ "value": "open"
+ }
+ ],
+ "logic": "AND"
+}
+```
+
+### Operators
+
+- `equals` - Exact match
+- `not_equals` - Not equal
+- `in` - Value in list
+- `not_in` - Value not in list
+- `matches` - Regex match
+- `not_matches` - Regex does not match
+- `contains` - String contains value
+- `not_contains` - String does not contain value
+
+### Logic
+
+- `AND` - All conditions must match (default)
+- `OR` - Any condition matches
+
+### Field Paths
+
+For API feeds, use `raw.field` or `raw.nested.field` to access API response fields:
+- `raw.Priority`
+- `raw.EventStatus`
+- `raw.StartRoadwayLocation.RoadName`
+
+## Sort Configuration
+
+Sort items before processing:
+
+```json
+{
+ "field": "raw.LastUpdatedTime",
+ "order": "desc"
+}
+```
+
+### Sort Options
+
+- `field` - Field path to sort by (e.g., `raw.LastUpdatedTime`, `raw.Priority`, `published`)
+- `order` - `asc` (ascending) or `desc` (descending)
+
+### Date Format Support
+
+The sort function supports:
+- ISO format dates
+- Microsoft JSON date format: `/Date(timestamp-offset)/` (e.g., WSDOT API)
+- Unix timestamps
+- Common date string formats
+
+## Message Queuing
+
+Messages are queued and sent at configured intervals to prevent rate limiting:
+
+- `message_send_interval_seconds` - Time between sending messages from the same feed (default: 2.0 seconds)
+- Messages are automatically queued and processed in order
+- Each feed maintains its own send interval
+
+## Deduplication
+
+The system automatically prevents duplicate posts:
+
+- Items are tracked by ID in the database
+- Previously processed items are skipped
+- Works correctly even when sorting changes item order
+- Database-backed deduplication ensures reliability across restarts
+
+## Rate Limiting
+
+The feed manager implements rate limiting:
+
+- Per-domain rate limiting (default: 5 seconds between requests to same domain)
+- Configurable via `feed_rate_limit_seconds`
+- Prevents overwhelming feed sources
+
+## Web Interface
+
+The feed management system includes a web interface accessible at `/feeds`:
+
+- View all feed subscriptions
+- Add/edit/delete feeds
+- Preview output format with live feed data
+- View feed statistics and activity
+- Monitor errors
+
+## Command Interface
+
+Feeds can be managed via mesh commands. The feed command requires admin access and must be sent as a direct message (DM) to the bot. The command is enabled by default.
+
+**Command Format:** `feed [arguments]` (DM only)
+
+### Available Commands
+
+- `feed subscribe [name] [api_config]` - Subscribe to a feed
+- `feed unsubscribe [channel]` - Unsubscribe from a feed (by ID or URL)
+- `feed list [channel]` - List all feed subscriptions (optionally filtered by channel)
+- `feed status ` - Show detailed status for a feed
+- `feed enable ` - Enable a feed subscription
+- `feed disable ` - Disable a feed subscription
+- `feed update [interval_seconds]` - Update feed settings
+- `feed test ` - Test/validate a feed URL
+
+### Examples
+
+```
+feed subscribe rss https://alerts.example.com/rss emergency "Emergency Alerts"
+feed subscribe api https://api.example.com/alerts emergency "API Alerts" '{"headers": {"Authorization": "Bearer TOKEN"}}'
+feed list
+feed list #alerts
+feed status 1
+feed enable 1
+feed disable 1
+feed unsubscribe 1
+feed update 1 60
+```
+
+**Note:** The feed command requires admin access. API feeds require JSON configuration as the last argument when subscribing.
+
+## Best Practices
+
+1. **Check Intervals**: Set appropriate intervals based on feed update frequency (60-300 seconds typical)
+
+2. **Message Formatting**: Keep messages under 130 characters for mesh compatibility
+
+3. **Filtering**: Use filters to reduce noise and only send relevant items
+
+4. **Rate Limiting**: Respect feed source rate limits by configuring appropriate intervals
+
+5. **Error Handling**: Monitor feed errors in the web interface and adjust configuration as needed
+
+6. **Testing**: Use the preview feature in the web interface to test output formats before enabling feeds
+
+## Troubleshooting
+
+### Feeds Not Polling
+
+- Verify `feed_manager_enabled = true` in config
+- Check that feeds are enabled in the database
+- Review bot logs for errors
+- Ensure bot is connected to mesh network
+
+### Items Not Appearing
+
+- Check filter configuration - items may be filtered out
+- Verify output format is correct
+- Check feed activity log in web interface
+- Review error log for parsing issues
+
+### Duplicate Messages
+
+- Deduplication is automatic - check if item IDs are changing
+- Verify `last_item_id` is being updated correctly
+- Check database for processed items
+
+### Rate Limiting Issues
+
+- Increase `feed_rate_limit_seconds` in config
+- Increase `message_send_interval_seconds` for specific feeds
+- Reduce `check_interval_seconds` to poll less frequently
+
diff --git a/modules/channel_manager.py b/modules/channel_manager.py
index ef0d3bb..a9c7323 100644
--- a/modules/channel_manager.py
+++ b/modules/channel_manager.py
@@ -7,6 +7,7 @@ Handles efficient concurrent channel fetching with caching
import asyncio
import sys
import os
+import hashlib
from typing import Dict, Any, List, Optional
from meshcore import EventType
@@ -104,8 +105,70 @@ class ChannelManager:
# Update the bot's meshcore channels for compatibility
self.bot.meshcore.channels = self._channels_cache
+ # Store channels in database for web viewer access
+ self._store_channels_in_db(valid_channels)
+
return valid_channels
+ def _store_channels_in_db(self, channels: List[Dict[str, Any]]):
+ """Store channel information in database for web viewer access (full refresh - clears all first)"""
+ try:
+ import sqlite3
+ db_path = self.bot.db_manager.db_path
+
+ with sqlite3.connect(db_path) as conn:
+ cursor = conn.cursor()
+
+ # Clear existing channels (full refresh)
+ cursor.execute('DELETE FROM channels')
+
+ # Insert all channels
+ for channel in channels:
+ self._insert_channel_in_db(cursor, channel)
+
+ conn.commit()
+ self.logger.debug(f"Stored {len(channels)} channels in database (full refresh)")
+ except Exception as e:
+ self.logger.warning(f"Failed to store channels in database: {e}")
+
+ def _store_single_channel_in_db(self, channel: Dict[str, Any]):
+ """Store or update a single channel in database (without clearing others)"""
+ try:
+ import sqlite3
+ db_path = self.bot.db_manager.db_path
+
+ with sqlite3.connect(db_path) as conn:
+ cursor = conn.cursor()
+ self._insert_channel_in_db(cursor, channel)
+ conn.commit()
+ self.logger.debug(f"Stored/updated channel {channel.get('channel_idx')} in database")
+ except Exception as e:
+ self.logger.warning(f"Failed to store single channel in database: {e}")
+
+ def _insert_channel_in_db(self, cursor, channel: Dict[str, Any]):
+ """Helper method to insert/update a single channel in database"""
+ channel_idx = channel.get('channel_idx')
+ channel_name = channel.get('channel_name', '')
+ channel_key_hex = channel.get('channel_key_hex', '')
+
+ # Determine channel type based on key derivation
+ # If key matches hashtag derivation, it's a hashtag channel
+ channel_type = 'hashtag' # Default assumption
+ if channel_name and channel_key_hex:
+ # Check if key matches hashtag derivation
+ expected_key = self.generate_hashtag_key(channel_name)
+ if expected_key.hex() == channel_key_hex:
+ channel_type = 'hashtag'
+ else:
+ channel_type = 'custom'
+
+ if channel_name: # Only store non-empty channels
+ cursor.execute('''
+ INSERT OR REPLACE INTO channels
+ (channel_idx, channel_name, channel_type, channel_key_hex, last_updated)
+ VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP)
+ ''', (channel_idx, channel_name, channel_type, channel_key_hex))
+
async def _fetch_single_channel(self, channel_idx: int) -> Optional[Dict[str, Any]]:
"""
Fetch a single channel with error handling
@@ -293,3 +356,354 @@ class ChannelManager:
"""Invalidate the channels cache"""
self._cache_valid = False
self.logger.debug("Channels cache invalidated")
+
+ @staticmethod
+ def generate_hashtag_key(channel_name: str) -> bytes:
+ """
+ Generate a hashtag channel key from the channel name
+
+ The key is the first 16 bytes of the SHA256 hash of the channel name
+ (including the # symbol), converted to lowercase.
+
+ Args:
+ channel_name: The channel name (e.g., "#general" or "general")
+
+ Returns:
+ 16-byte key for the hashtag channel
+ """
+ # Ensure channel name starts with # and is lowercase
+ if not channel_name.startswith('#'):
+ channel_name = '#' + channel_name
+ channel_name_lower = channel_name.lower()
+
+ # Compute SHA256 hash
+ hash_obj = hashlib.sha256(channel_name_lower.encode('utf-8'))
+ hash_bytes = hash_obj.digest()
+
+ # Take first 16 bytes
+ return hash_bytes[:16]
+
+ async def add_hashtag_channel(self, channel_idx: int, channel_name: str) -> bool:
+ """
+ Add or update a hashtag channel on the radio
+
+ Hashtag channels use publicly derivable keys based on the channel name.
+ The firmware automatically generates the key when the channel name starts with #.
+
+ Args:
+ channel_idx: The channel index (0-39)
+ channel_name: The name of the channel (with or without # prefix)
+
+ Returns:
+ True if successful, False otherwise
+ """
+ # Ensure channel name has # prefix for consistency
+ if not channel_name.startswith('#'):
+ channel_name = '#' + channel_name
+
+ self.logger.info(f"Adding hashtag channel {channel_idx}: {channel_name}")
+
+ # Use the simplified add_channel method - firmware will auto-generate key
+ return await self.add_channel(channel_idx, channel_name)
+
+ async def add_channel(self, channel_idx: int, channel_name: str, channel_secret: Optional[bytes] = None, channel_secret_hex: Optional[str] = None) -> bool:
+ """
+ Add or update a channel on the radio
+
+ For hashtag channels (name starts with #), the firmware automatically generates the key.
+ For custom channels, provide either channel_secret (bytes) or channel_secret_hex (hex string).
+
+ Args:
+ channel_idx: The channel index (0-39)
+ channel_name: The name of the channel
+ channel_secret: Optional 16-byte encryption key for custom channels
+ channel_secret_hex: Optional hex string (32 chars) for the encryption key. Takes precedence over channel_secret.
+
+ Returns:
+ True if successful, False otherwise
+ """
+ if not self.bot.connected or not self.bot.meshcore:
+ self.logger.error("Not connected to MeshCore node")
+ return False
+
+ if channel_idx < 0 or channel_idx >= self.max_channels:
+ self.logger.error(f"Channel index {channel_idx} out of range (0-{self.max_channels-1})")
+ return False
+
+ try:
+ # Check if this is a hashtag channel (firmware auto-generates key)
+ is_hashtag = channel_name.startswith('#')
+
+ # For custom channels, validate and prepare the key
+ if not is_hashtag:
+ if channel_secret_hex:
+ # Validate hex string
+ if len(channel_secret_hex) != 32:
+ self.logger.error(f"Channel secret hex must be exactly 32 characters (16 bytes), got {len(channel_secret_hex)}")
+ return False
+ try:
+ channel_secret = bytes.fromhex(channel_secret_hex)
+ except ValueError as e:
+ self.logger.error(f"Invalid hex string for channel secret: {e}")
+ return False
+ elif channel_secret is None:
+ self.logger.error("Custom channel requires a channel key (channel_secret or channel_secret_hex)")
+ return False
+ elif len(channel_secret) != 16:
+ self.logger.error(f"Channel secret must be exactly 16 bytes, got {len(channel_secret)}")
+ return False
+
+ self.logger.info(f"Adding custom channel {channel_idx}: {channel_name} (key: {channel_secret.hex()[:8]}...)")
+ else:
+ self.logger.info(f"Adding hashtag channel {channel_idx}: {channel_name} (firmware will auto-generate key)")
+
+ # Use meshcore.commands.set_channel API directly
+ if hasattr(self.bot.meshcore, 'commands') and hasattr(self.bot.meshcore.commands, 'set_channel'):
+ # For hashtag channels, just pass the name (firmware generates key)
+ if is_hashtag:
+ res = await self.bot.meshcore.commands.set_channel(channel_idx, channel_name)
+ else:
+ # For custom channels, we need to pass the key
+ # Check if set_channel accepts a key parameter
+ # Try with key as third parameter
+ try:
+ res = await self.bot.meshcore.commands.set_channel(channel_idx, channel_name, channel_secret)
+ except TypeError:
+ # If that doesn't work, try with hex string
+ try:
+ res = await self.bot.meshcore.commands.set_channel(channel_idx, channel_name, channel_secret_hex or channel_secret.hex())
+ except TypeError:
+ # Fallback to CLI method if API doesn't support key parameter
+ self.logger.warning("meshcore.commands.set_channel doesn't accept key parameter, using CLI fallback")
+ return await self._add_channel_via_cli(channel_idx, channel_name, channel_secret.hex() if channel_secret else channel_secret_hex)
+
+ # Check for errors
+ if hasattr(res, 'type') and res.type == EventType.ERROR:
+ self.logger.error(f"Failed to set channel {channel_idx}: {res.payload if hasattr(res, 'payload') else 'Unknown error'}")
+ return False
+
+ # Fetch the channel back to get the generated key and verify
+ res = await self.bot.meshcore.commands.get_channel(channel_idx)
+
+ if hasattr(res, 'type') and res.type == EventType.ERROR:
+ self.logger.error(f"Failed to get channel {channel_idx} after setting: {res.payload if hasattr(res, 'payload') else 'Unknown error'}")
+ return False
+
+ # Extract channel info from response
+ if hasattr(res, 'payload'):
+ channel_info = res.payload
+ else:
+ # Fallback: try to get from event subscription
+ channel_info = await self._fetch_single_channel(channel_idx)
+ if not channel_info:
+ self.logger.error(f"Could not retrieve channel {channel_idx} after setting")
+ return False
+
+ # Verify channel was set correctly
+ if channel_info.get('channel_name') != channel_name:
+ self.logger.error(f"Channel name mismatch: expected {channel_name}, got {channel_info.get('channel_name')}")
+ return False
+
+ # For custom channels, verify the key matches
+ if not is_hashtag:
+ channel_secret_from_device = channel_info.get('channel_secret', b'')
+ if isinstance(channel_secret_from_device, bytes) and channel_secret_from_device != channel_secret:
+ self.logger.error(f"Channel key mismatch for custom channel {channel_idx}")
+ return False
+
+ # Update cache and database
+ channel_info['channel_key_hex'] = channel_info.get('channel_secret', b'').hex() if isinstance(channel_info.get('channel_secret'), bytes) else ''
+ self._channels_cache[channel_idx] = channel_info
+ self._store_single_channel_in_db(channel_info)
+
+ self.logger.info(f"Successfully added channel {channel_idx}: {channel_name}")
+ return True
+ else:
+ # Fallback to CLI method if commands API not available
+ self.logger.warning("meshcore.commands.set_channel not available, using CLI fallback")
+ channel_secret_hex = channel_secret.hex() if channel_secret else channel_secret_hex
+ if is_hashtag:
+ # For hashtag, generate key ourselves as fallback
+ channel_secret = self.generate_hashtag_key(channel_name)
+ channel_secret_hex = channel_secret.hex()
+ return await self._add_channel_via_cli(channel_idx, channel_name, channel_secret_hex)
+
+ except Exception as e:
+ self.logger.error(f"Error adding channel {channel_idx}: {e}")
+ import traceback
+ self.logger.debug(traceback.format_exc())
+ return False
+
+ async def _add_channel_via_cli(self, channel_idx: int, channel_name: str, channel_secret_hex: str) -> bool:
+ """
+ Fallback method to add channel using CLI wrapper (for older meshcore versions)
+
+ Args:
+ channel_idx: The channel index
+ channel_name: The channel name
+ channel_secret_hex: The channel key as hex string
+
+ Returns:
+ True if successful, False otherwise
+ """
+ try:
+ # Subscribe to channel info events to confirm the channel was set
+ channel_set = False
+ event_received = asyncio.Event()
+
+ async def on_channel_info(event):
+ nonlocal channel_set
+ if event.payload.get('channel_idx') == channel_idx:
+ payload = event.payload
+ if payload.get('channel_name') == channel_name:
+ channel_set = True
+ event_received.set()
+
+ subscription = self.bot.meshcore.subscribe(EventType.CHANNEL_INFO, on_channel_info)
+
+ try:
+ from meshcore_cli.meshcore_cli import next_cmd
+
+ # Suppress raw JSON output
+ with open(os.devnull, 'w') as devnull:
+ old_stdout = sys.stdout
+ sys.stdout = devnull
+ try:
+ await next_cmd(
+ self.bot.meshcore,
+ ["set_channel", str(channel_idx), channel_name, channel_secret_hex]
+ )
+ finally:
+ sys.stdout = old_stdout
+
+ # Wait for confirmation with timeout
+ try:
+ await asyncio.wait_for(event_received.wait(), timeout=self._fetch_timeout * 2)
+ except asyncio.TimeoutError:
+ self.logger.warning(f"Timeout waiting for channel {channel_idx} set confirmation")
+ await asyncio.sleep(0.5)
+ result = await self._fetch_single_channel(channel_idx)
+ if result and result.get('channel_name') == channel_name:
+ channel_set = True
+
+ if channel_set:
+ # Update cache
+ result = await self._fetch_single_channel(channel_idx)
+ if result:
+ self._channels_cache[channel_idx] = result
+ self._store_single_channel_in_db(result)
+ self.logger.info(f"Successfully added channel {channel_idx}: {channel_name}")
+ return True
+ else:
+ self.logger.warning(f"Channel {channel_idx} was set but could not be verified")
+ return False
+ else:
+ self.logger.error(f"Failed to set channel {channel_idx}")
+ return False
+
+ finally:
+ self.bot.meshcore.unsubscribe(subscription)
+
+ except Exception as e:
+ self.logger.error(f"Error in CLI fallback for channel {channel_idx}: {e}")
+ return False
+
+ async def remove_channel(self, channel_idx: int) -> bool:
+ """
+ Remove a channel from the radio by clearing it
+
+ Args:
+ channel_idx: The channel index to remove
+
+ Returns:
+ True if successful, False otherwise
+ """
+ if not self.bot.connected or not self.bot.meshcore:
+ self.logger.error("Not connected to MeshCore node")
+ return False
+
+ if channel_idx < 0 or channel_idx >= self.max_channels:
+ self.logger.error(f"Channel index {channel_idx} out of range (0-{self.max_channels-1})")
+ return False
+
+ try:
+ self.logger.info(f"Removing channel {channel_idx}")
+
+ # Create all-zero channel secret (16 bytes) to clear the channel
+ empty_secret = b'\x00' * 16
+ empty_secret_hex = empty_secret.hex()
+
+ # Subscribe to channel info events to confirm the channel was cleared
+ channel_cleared = False
+ event_received = asyncio.Event()
+
+ async def on_channel_info(event):
+ nonlocal channel_cleared
+ if event.payload.get('channel_idx') == channel_idx:
+ payload = event.payload
+ event_secret = payload.get('channel_secret', b'')
+ # Check if the channel was cleared (all zeros or empty name)
+ if isinstance(event_secret, bytes) and event_secret == empty_secret:
+ channel_cleared = True
+ event_received.set()
+ elif not payload.get('channel_name') or payload.get('channel_name') == '':
+ channel_cleared = True
+ event_received.set()
+
+ subscription = self.bot.meshcore.subscribe(EventType.CHANNEL_INFO, on_channel_info)
+
+ try:
+ from meshcore_cli.meshcore_cli import next_cmd
+
+ # Suppress raw JSON output
+ with open(os.devnull, 'w') as devnull:
+ old_stdout = sys.stdout
+ sys.stdout = devnull
+ try:
+ # Clear the channel by setting it with empty name and all-zero secret
+ # Format: set_channel ""
+ await next_cmd(
+ self.bot.meshcore,
+ ["set_channel", str(channel_idx), "", empty_secret_hex]
+ )
+ finally:
+ sys.stdout = old_stdout
+
+ # Wait for confirmation with timeout
+ try:
+ await asyncio.wait_for(event_received.wait(), timeout=self._fetch_timeout * 2)
+ except asyncio.TimeoutError:
+ self.logger.warning(f"Timeout waiting for channel {channel_idx} removal confirmation")
+ # Still try to verify by fetching the channel
+ await asyncio.sleep(0.5)
+ result = await self._fetch_single_channel(channel_idx)
+ if not result or not result.get('channel_name'):
+ channel_cleared = True
+
+ if channel_cleared:
+ # Remove from cache
+ if channel_idx in self._channels_cache:
+ del self._channels_cache[channel_idx]
+ # Update database - remove the channel
+ try:
+ import sqlite3
+ db_path = self.bot.db_manager.db_path
+ with sqlite3.connect(db_path) as conn:
+ cursor = conn.cursor()
+ cursor.execute('DELETE FROM channels WHERE channel_idx = ?', (channel_idx,))
+ conn.commit()
+ except Exception as e:
+ self.logger.warning(f"Failed to remove channel from database: {e}")
+ self.logger.info(f"Successfully removed channel {channel_idx}")
+ return True
+ else:
+ self.logger.error(f"Failed to remove channel {channel_idx}")
+ return False
+
+ finally:
+ # Unsubscribe
+ self.bot.meshcore.unsubscribe(subscription)
+
+ except Exception as e:
+ self.logger.error(f"Error removing channel {channel_idx}: {e}")
+ return False
\ No newline at end of file
diff --git a/modules/command_manager.py b/modules/command_manager.py
index d89b839..6218346 100644
--- a/modules/command_manager.py
+++ b/modules/command_manager.py
@@ -205,7 +205,24 @@ class CommandManager:
async def handle_advert_command(self, message: MeshMessage):
"""Handle the advert command from DM"""
- await self.commands['advert'].execute(message)
+ command = self.commands['advert']
+ success = await command.execute(message)
+
+ # Small delay to ensure send_response has completed
+ await asyncio.sleep(0.1)
+
+ # Determine if a response was sent
+ response_sent = False
+ if hasattr(command, 'last_response') and command.last_response:
+ response_sent = True
+ elif hasattr(self, '_last_response') and self._last_response:
+ response_sent = True
+
+ # Record command execution in stats database
+ if 'stats' in self.commands:
+ stats_command = self.commands['stats']
+ if stats_command:
+ stats_command.record_command(message, 'advert', response_sent)
async def send_dm(self, recipient_id: str, content: str) -> bool:
"""Send a direct message using meshcore-cli command"""
@@ -528,6 +545,7 @@ class CommandManager:
# Check if command can execute (cooldown, DM requirements, etc.)
if not command.can_execute_now(message):
+ response_sent = False
# For DM-only commands in public channels, only show error if channel is allowed
# (i.e., channel is in monitor_channels or command's allowed_channels)
# This prevents prompting users in channels where the command shouldn't work at all
@@ -536,10 +554,12 @@ class CommandManager:
if command.is_channel_allowed(message):
error_msg = command.translate('errors.dm_only', command=command_name)
await self.send_response(message, error_msg)
+ response_sent = True
# Otherwise, silently ignore (channel not configured for this command)
elif command.requires_admin_access():
error_msg = command.translate('errors.access_denied', command=command_name)
await self.send_response(message, error_msg)
+ response_sent = True
elif hasattr(command, 'get_remaining_cooldown') and callable(command.get_remaining_cooldown):
# Check if it's the per-user version (takes user_id parameter)
import inspect
@@ -552,6 +572,14 @@ class CommandManager:
if remaining > 0:
error_msg = command.translate('errors.cooldown', command=command_name, seconds=remaining)
await self.send_response(message, error_msg)
+ response_sent = True
+
+ # Record command execution in stats database (even if it failed checks)
+ if 'stats' in self.commands:
+ stats_command = self.commands['stats']
+ if stats_command:
+ stats_command.record_command(message, command_name, response_sent)
+
return
try:
@@ -567,22 +595,33 @@ class CommandManager:
# Execute the command
success = await command.execute(message)
- # Capture command data for web viewer (with small delay to ensure response is set)
+ # Small delay to ensure send_response has completed
+ await asyncio.sleep(0.1)
+
+ # Determine if a response was sent by checking response tracking
+ response_sent = False
+ response = None
+ if hasattr(command, 'last_response') and command.last_response:
+ response = command.last_response
+ response_sent = True
+ elif hasattr(self, '_last_response') and self._last_response:
+ response = self._last_response
+ response_sent = True
+
+ # Record command execution in stats database
+ if 'stats' in self.commands:
+ stats_command = self.commands['stats']
+ if stats_command:
+ stats_command.record_command(message, command_name, response_sent)
+
+ # Capture command data for web viewer
if (hasattr(self.bot, 'web_viewer_integration') and
self.bot.web_viewer_integration and
self.bot.web_viewer_integration.bot_integration):
try:
- # Small delay to ensure send_response has completed
- await asyncio.sleep(0.1)
-
- # Get the response that was sent (if any)
- # Prioritize command.last_response (full response) over _last_response (may be split)
- # This ensures commands like path that split messages still show full response in webviewer
- response = "Command executed" # Default response
- if hasattr(command, 'last_response') and command.last_response:
- response = command.last_response
- elif hasattr(self, '_last_response') and self._last_response:
- response = self._last_response
+ # Use the response we found, or default
+ if response is None:
+ response = "Command executed"
self.bot.web_viewer_integration.bot_integration.capture_command(
message, command_name, response, success if success is not None else True
@@ -596,6 +635,12 @@ class CommandManager:
error_msg = command.translate('errors.execution_error', command=command_name, error=str(e))
await self.send_response(message, error_msg)
+ # Record command execution in stats database (error response was sent)
+ if 'stats' in self.commands:
+ stats_command = self.commands['stats']
+ if stats_command:
+ stats_command.record_command(message, command_name, True) # Error message counts as response
+
# Capture failed command for web viewer
if (hasattr(self.bot, 'web_viewer_integration') and
self.bot.web_viewer_integration and
diff --git a/modules/commands/feed_command.py b/modules/commands/feed_command.py
new file mode 100644
index 0000000..c563c11
--- /dev/null
+++ b/modules/commands/feed_command.py
@@ -0,0 +1,417 @@
+#!/usr/bin/env python3
+"""
+Feed command for the MeshCore Bot
+Handles RSS and API feed subscription management
+"""
+
+import json
+import re
+from typing import Optional, List, Dict, Any
+from urllib.parse import urlparse
+from .base_command import BaseCommand
+from ..models import MeshMessage
+
+
+class FeedCommand(BaseCommand):
+ """Handles feed subscription management"""
+
+ # Plugin metadata
+ name = "feed"
+ keywords = ['feed', 'feeds', 'rss', 'subscription', 'subscriptions']
+ description = "Manage RSS and API feed subscriptions (usage: feed subscribe rss [name])"
+ category = "admin"
+ requires_dm = True
+ cooldown_seconds = 2
+
+ def __init__(self, bot):
+ super().__init__(bot)
+ self.db_path = bot.db_manager.db_path
+
+ def can_execute(self, message: MeshMessage) -> bool:
+ """Check if this command can be executed (admin only)"""
+ if not self.requires_admin_access():
+ return False
+ return super().can_execute(message)
+
+ def requires_admin_access(self) -> bool:
+ """Feed command requires admin access"""
+ return True
+
+ async def execute(self, message: MeshMessage) -> bool:
+ """Execute the feed command"""
+ content = message.content.strip()
+
+ # Handle exclamation prefix
+ if content.startswith('!'):
+ content = content[1:].strip()
+
+ # Parse command
+ parts = content.split()
+ if len(parts) < 2:
+ return await self.send_response(message, self.get_help_text())
+
+ subcommand = parts[1].lower()
+
+ if subcommand == 'subscribe':
+ return await self._handle_subscribe(message, parts[2:])
+ elif subcommand == 'unsubscribe':
+ return await self._handle_unsubscribe(message, parts[2:])
+ elif subcommand == 'list':
+ return await self._handle_list(message, parts[2:])
+ elif subcommand == 'status':
+ return await self._handle_status(message, parts[2:])
+ elif subcommand == 'test':
+ return await self._handle_test(message, parts[2:])
+ elif subcommand == 'enable':
+ return await self._handle_enable_disable(message, parts[2:], True)
+ elif subcommand == 'disable':
+ return await self._handle_enable_disable(message, parts[2:], False)
+ elif subcommand == 'update':
+ return await self._handle_update(message, parts[2:])
+ else:
+ return await self.send_response(message, self.get_help_text())
+
+ def get_help_text(self) -> str:
+ """Get help text for feed command"""
+ return """Feed Command Usage:
+feed subscribe [name]
+feed unsubscribe
+feed list [channel]
+feed status
+feed test
+feed enable
+feed disable
+feed update [interval_seconds]
+
+Examples:
+feed subscribe rss https://alerts.example.com/rss emergency "Emergency Alerts"
+feed subscribe api https://api.example.com/alerts emergency "API Alerts" '{"headers": {"Authorization": "Bearer TOKEN"}}'
+feed list
+feed status 1"""
+
+ async def _handle_subscribe(self, message: MeshMessage, args: List[str]) -> bool:
+ """Handle feed subscribe command"""
+ if len(args) < 3:
+ return await self.send_response(message, "Usage: feed subscribe [name] [api_config]")
+
+ feed_type = args[0].lower()
+ if feed_type not in ['rss', 'api']:
+ return await self.send_response(message, "Feed type must be 'rss' or 'api'")
+
+ feed_url = args[1]
+ channel_name = args[2]
+ feed_name = args[3] if len(args) > 3 else None
+ api_config = args[4] if len(args) > 4 and feed_type == 'api' else None
+
+ # Validate URL
+ if not self._validate_url(feed_url):
+ return await self.send_response(message, "Invalid URL format")
+
+ # Validate channel exists
+ channel_num = self.bot.channel_manager.get_channel_number(channel_name)
+ if channel_num is None:
+ return await self.send_response(message, f"Channel '{channel_name}' not found. Create it first or use a valid channel name.")
+
+ # Parse API config if provided
+ api_config_json = None
+ if feed_type == 'api' and api_config:
+ try:
+ api_config_json = json.loads(api_config)
+ except json.JSONDecodeError:
+ return await self.send_response(message, "Invalid API config JSON")
+
+ # Create subscription
+ try:
+ feed_id = self._create_subscription(
+ feed_type=feed_type,
+ feed_url=feed_url,
+ channel_name=channel_name,
+ feed_name=feed_name,
+ api_config=api_config_json
+ )
+
+ response = f"Subscribed to {feed_type.upper()} feed"
+ if feed_name:
+ response += f" '{feed_name}'"
+ response += f" -> channel: {channel_name} (ID: {feed_id})"
+ return await self.send_response(message, response)
+
+ except Exception as e:
+ self.logger.error(f"Error creating subscription: {e}")
+ return await self.send_response(message, f"Error creating subscription: {str(e)}")
+
+ async def _handle_unsubscribe(self, message: MeshMessage, args: List[str]) -> bool:
+ """Handle feed unsubscribe command"""
+ if len(args) < 1:
+ return await self.send_response(message, "Usage: feed unsubscribe [channel]")
+
+ identifier = args[0]
+ channel_name = args[1] if len(args) > 1 else None
+
+ try:
+ # Try as ID first
+ try:
+ feed_id = int(identifier)
+ success = self._delete_subscription_by_id(feed_id)
+ except ValueError:
+ # Try as URL
+ if channel_name:
+ success = self._delete_subscription_by_url(identifier, channel_name)
+ else:
+ return await self.send_response(message, "Channel name required when using URL")
+
+ if success:
+ return await self.send_response(message, f"Unsubscribed from feed (ID: {identifier})")
+ else:
+ return await self.send_response(message, "Feed subscription not found")
+
+ except Exception as e:
+ self.logger.error(f"Error unsubscribing: {e}")
+ return await self.send_response(message, f"Error unsubscribing: {str(e)}")
+
+ async def _handle_list(self, message: MeshMessage, args: List[str]) -> bool:
+ """Handle feed list command"""
+ channel_filter = args[0] if args else None
+
+ try:
+ feeds = self._get_subscriptions(channel_filter)
+
+ if not feeds:
+ response = "No feed subscriptions"
+ if channel_filter:
+ response += f" for channel '{channel_filter}'"
+ return await self.send_response(message, response)
+
+ response = f"Feed Subscriptions ({len(feeds)}):\n"
+ for feed in feeds[:10]: # Limit to 10 for mesh message
+ status = "enabled" if feed['enabled'] else "disabled"
+ name = feed.get('feed_name') or feed['feed_url'][:30]
+ response += f"{feed['id']}. {name} ({feed['feed_type']}) -> {feed['channel_name']} [{status}]\n"
+
+ if len(feeds) > 10:
+ response += f"({len(feeds) - 10} more...)"
+
+ return await self.send_response(message, response)
+
+ except Exception as e:
+ self.logger.error(f"Error listing feeds: {e}")
+ return await self.send_response(message, f"Error listing feeds: {str(e)}")
+
+ async def _handle_status(self, message: MeshMessage, args: List[str]) -> bool:
+ """Handle feed status command"""
+ if not args:
+ return await self.send_response(message, "Usage: feed status ")
+
+ try:
+ feed_id = int(args[0])
+ feed = self._get_subscription_by_id(feed_id)
+
+ if not feed:
+ return await self.send_response(message, f"Feed subscription {feed_id} not found")
+
+ status = "enabled" if feed['enabled'] else "disabled"
+ last_check = feed.get('last_check_time') or "Never"
+ last_item = feed.get('last_item_id') or "None"
+
+ response = f"Feed {feed_id} Status:\n"
+ response += f"Name: {feed.get('feed_name') or 'N/A'}\n"
+ response += f"Type: {feed['feed_type']}\n"
+ response += f"URL: {feed['feed_url']}\n"
+ response += f"Channel: {feed['channel_name']}\n"
+ response += f"Status: {status}\n"
+ response += f"Interval: {feed.get('check_interval_seconds', 300)}s\n"
+ response += f"Last check: {last_check}\n"
+ response += f"Last item: {last_item[:30] if last_item != 'None' else 'None'}"
+
+ return await self.send_response(message, response)
+
+ except ValueError:
+ return await self.send_response(message, "Invalid feed ID")
+ except Exception as e:
+ self.logger.error(f"Error getting feed status: {e}")
+ return await self.send_response(message, f"Error getting feed status: {str(e)}")
+
+ async def _handle_test(self, message: MeshMessage, args: List[str]) -> bool:
+ """Handle feed test command"""
+ if not args:
+ return await self.send_response(message, "Usage: feed test ")
+
+ feed_url = args[0]
+
+ if not self._validate_url(feed_url):
+ return await self.send_response(message, "Invalid URL format")
+
+ # Test would require feed_manager to be available
+ # For now, just validate URL
+ return await self.send_response(message, f"URL validated: {feed_url}\n(Full test requires feed manager)")
+
+ async def _handle_enable_disable(self, message: MeshMessage, args: List[str], enable: bool) -> bool:
+ """Handle enable/disable command"""
+ if not args:
+ return await self.send_response(message, f"Usage: feed {'enable' if enable else 'disable'} ")
+
+ try:
+ feed_id = int(args[0])
+ success = self._set_subscription_enabled(feed_id, enable)
+
+ if success:
+ status = "enabled" if enable else "disabled"
+ return await self.send_response(message, f"Feed {feed_id} {status}")
+ else:
+ return await self.send_response(message, f"Feed subscription {feed_id} not found")
+
+ except ValueError:
+ return await self.send_response(message, "Invalid feed ID")
+ except Exception as e:
+ self.logger.error(f"Error setting feed status: {e}")
+ return await self.send_response(message, f"Error: {str(e)}")
+
+ async def _handle_update(self, message: MeshMessage, args: List[str]) -> bool:
+ """Handle update command"""
+ if not args:
+ return await self.send_response(message, "Usage: feed update [interval_seconds]")
+
+ try:
+ feed_id = int(args[0])
+ interval = int(args[1]) if len(args) > 1 else None
+
+ success = self._update_subscription(feed_id, interval)
+
+ if success:
+ response = f"Feed {feed_id} updated"
+ if interval:
+ response += f" (interval: {interval}s)"
+ return await self.send_response(message, response)
+ else:
+ return await self.send_response(message, f"Feed subscription {feed_id} not found")
+
+ except ValueError:
+ return await self.send_response(message, "Invalid feed ID or interval")
+ except Exception as e:
+ self.logger.error(f"Error updating feed: {e}")
+ return await self.send_response(message, f"Error: {str(e)}")
+
+ def _validate_url(self, url: str) -> bool:
+ """Validate URL format"""
+ try:
+ result = urlparse(url)
+ return all([result.scheme in ['http', 'https'], result.netloc])
+ except Exception:
+ return False
+
+ def _create_subscription(self, feed_type: str, feed_url: str, channel_name: str,
+ feed_name: Optional[str] = None, api_config: Optional[Dict] = None) -> int:
+ """Create a new feed subscription"""
+ import sqlite3
+
+ with sqlite3.connect(self.db_path) as conn:
+ cursor = conn.cursor()
+
+ # Get default check interval
+ default_interval = self.bot.config.getint('Feed_Manager', 'default_check_interval_seconds', fallback=300)
+
+ api_config_str = json.dumps(api_config) if api_config else None
+
+ cursor.execute('''
+ INSERT INTO feed_subscriptions
+ (feed_type, feed_url, channel_name, feed_name, check_interval_seconds, api_config)
+ VALUES (?, ?, ?, ?, ?, ?)
+ ''', (feed_type, feed_url, channel_name, feed_name, default_interval, api_config_str))
+
+ conn.commit()
+ return cursor.lastrowid
+
+ def _delete_subscription_by_id(self, feed_id: int) -> bool:
+ """Delete subscription by ID"""
+ import sqlite3
+
+ with sqlite3.connect(self.db_path) as conn:
+ cursor = conn.cursor()
+ cursor.execute('DELETE FROM feed_subscriptions WHERE id = ?', (feed_id,))
+ conn.commit()
+ return cursor.rowcount > 0
+
+ def _delete_subscription_by_url(self, feed_url: str, channel_name: str) -> bool:
+ """Delete subscription by URL and channel"""
+ import sqlite3
+
+ with sqlite3.connect(self.db_path) as conn:
+ cursor = conn.cursor()
+ cursor.execute('''
+ DELETE FROM feed_subscriptions
+ WHERE feed_url = ? AND channel_name = ?
+ ''', (feed_url, channel_name))
+ conn.commit()
+ return cursor.rowcount > 0
+
+ def _get_subscriptions(self, channel_filter: Optional[str] = None) -> List[Dict]:
+ """Get all subscriptions, optionally filtered by channel"""
+ import sqlite3
+
+ with sqlite3.connect(self.db_path) as conn:
+ conn.row_factory = sqlite3.Row
+ cursor = conn.cursor()
+
+ if channel_filter:
+ cursor.execute('''
+ SELECT * FROM feed_subscriptions
+ WHERE channel_name = ?
+ ORDER BY id
+ ''', (channel_filter,))
+ else:
+ cursor.execute('''
+ SELECT * FROM feed_subscriptions
+ ORDER BY id
+ ''')
+
+ rows = cursor.fetchall()
+ return [dict(row) for row in rows]
+
+ def _get_subscription_by_id(self, feed_id: int) -> Optional[Dict]:
+ """Get subscription by ID"""
+ import sqlite3
+
+ with sqlite3.connect(self.db_path) as conn:
+ conn.row_factory = sqlite3.Row
+ cursor = conn.cursor()
+ cursor.execute('SELECT * FROM feed_subscriptions WHERE id = ?', (feed_id,))
+ row = cursor.fetchone()
+ return dict(row) if row else None
+
+ def _set_subscription_enabled(self, feed_id: int, enabled: bool) -> bool:
+ """Enable or disable a subscription"""
+ import sqlite3
+
+ with sqlite3.connect(self.db_path) as conn:
+ cursor = conn.cursor()
+ cursor.execute('''
+ UPDATE feed_subscriptions
+ SET enabled = ?, updated_at = CURRENT_TIMESTAMP
+ WHERE id = ?
+ ''', (1 if enabled else 0, feed_id))
+ conn.commit()
+ return cursor.rowcount > 0
+
+ def _update_subscription(self, feed_id: int, interval: Optional[int] = None) -> bool:
+ """Update subscription settings"""
+ import sqlite3
+
+ with sqlite3.connect(self.db_path) as conn:
+ cursor = conn.cursor()
+
+ if interval:
+ cursor.execute('''
+ UPDATE feed_subscriptions
+ SET check_interval_seconds = ?, updated_at = CURRENT_TIMESTAMP
+ WHERE id = ?
+ ''', (interval, feed_id))
+ else:
+ cursor.execute('''
+ UPDATE feed_subscriptions
+ SET updated_at = CURRENT_TIMESTAMP
+ WHERE id = ?
+ ''', (feed_id,))
+
+ conn.commit()
+ return cursor.rowcount > 0
+
diff --git a/modules/commands/greeter_command.py b/modules/commands/greeter_command.py
index 83fecdf..6a5ab63 100644
--- a/modules/commands/greeter_command.py
+++ b/modules/commands/greeter_command.py
@@ -6,6 +6,7 @@ Greets users on their first public channel message with mesh information
import sqlite3
import time
+import asyncio
from datetime import datetime, timedelta
from typing import Optional, Dict, Any, List
from .base_command import BaseCommand
@@ -26,6 +27,9 @@ class GreeterCommand(BaseCommand):
self._init_greeter_tables()
self._load_config()
+ # Track pending greetings (for dead air delay)
+ self.pending_greetings = {} # key: (sender_id, channel), value: asyncio.Task
+
# Auto-backfill if enabled
if self.enabled and self.auto_backfill:
self.logger.info("Auto-backfill enabled - backfilling greeted users from historical data")
@@ -166,6 +170,14 @@ class GreeterCommand(BaseCommand):
self.greeting_parts = [part.strip() for part in self.greeting_message.split('|') if part.strip()]
else:
self.greeting_parts = [self.greeting_message]
+
+ # Dead air delay settings
+ self.dead_air_delay_seconds = self.get_config_value('Greeter_Command', 'dead_air_delay_seconds',
+ fallback=0, value_type='int')
+ self.defer_to_human_greeting = self.get_config_value('Greeter_Command', 'defer_to_human_greeting',
+ fallback=False, value_type='bool')
+ self.levenshtein_distance = self.get_config_value('Greeter_Command', 'levenshtein_distance',
+ fallback=0, value_type='int')
def _init_greeter_tables(self):
"""Initialize database tables for greeter tracking"""
@@ -508,9 +520,83 @@ class GreeterCommand(BaseCommand):
self.logger.error(f"Error starting rollout: {e}")
return False
+ def _levenshtein_distance(self, s1: str, s2: str) -> int:
+ """
+ Calculate Levenshtein distance between two strings
+
+ Args:
+ s1: First string
+ s2: Second string
+
+ Returns:
+ Levenshtein distance (number of edits needed)
+ """
+ if len(s1) < len(s2):
+ return self._levenshtein_distance(s2, s1)
+
+ if len(s2) == 0:
+ return len(s1)
+
+ previous_row = range(len(s2) + 1)
+ for i, c1 in enumerate(s1):
+ current_row = [i + 1]
+ for j, c2 in enumerate(s2):
+ insertions = previous_row[j + 1] + 1
+ deletions = current_row[j] + 1
+ substitutions = previous_row[j] + (c1 != c2)
+ current_row.append(min(insertions, deletions, substitutions))
+ previous_row = current_row
+
+ return previous_row[-1]
+
+ def _find_similar_greeted_user(self, sender_id: str, channel: str) -> Optional[str]:
+ """
+ Find if a user with a similar name (within Levenshtein distance) has been greeted
+
+ Args:
+ sender_id: The user's ID to check
+ channel: The channel name (used only if per_channel_greetings is True)
+
+ Returns:
+ The greeted sender_id if a similar one is found, None otherwise
+ """
+ if self.levenshtein_distance <= 0:
+ return None
+
+ try:
+ with sqlite3.connect(self.bot.db_manager.db_path) as conn:
+ cursor = conn.cursor()
+
+ if self.per_channel_greetings:
+ # Per-channel mode: check greeted users on this specific channel
+ cursor.execute('''
+ SELECT DISTINCT sender_id FROM greeted_users
+ WHERE channel = ?
+ ''', (channel,))
+ else:
+ # Global mode: check all greeted users (channel = NULL)
+ cursor.execute('''
+ SELECT DISTINCT sender_id FROM greeted_users
+ WHERE channel IS NULL
+ ''')
+
+ greeted_users = cursor.fetchall()
+
+ # Check each greeted user for similarity
+ for (greeted_id,) in greeted_users:
+ distance = self._levenshtein_distance(sender_id.lower(), greeted_id.lower())
+ if distance <= self.levenshtein_distance:
+ self.logger.debug(f"Found similar user: {greeted_id} (distance: {distance} from {sender_id})")
+ return greeted_id
+
+ return None
+ except Exception as e:
+ self.logger.error(f"Error checking for similar greeted users: {e}")
+ return None
+
def has_been_greeted(self, sender_id: str, channel: str) -> bool:
"""
- Check if a user has been greeted
+ Check if a user has been greeted (with optional Levenshtein distance matching)
Args:
sender_id: The user's ID
@@ -536,7 +622,17 @@ class GreeterCommand(BaseCommand):
WHERE sender_id = ? AND channel IS NULL
''', (sender_id,))
- return cursor.fetchone() is not None
+ if cursor.fetchone() is not None:
+ return True
+
+ # If exact match not found and Levenshtein distance is enabled, check for similar names
+ if self.levenshtein_distance > 0:
+ similar_user = self._find_similar_greeted_user(sender_id, channel)
+ if similar_user:
+ self.logger.info(f"User {sender_id} matches previously greeted user {similar_user} (Levenshtein distance enabled)")
+ return True
+
+ return False
except Exception as e:
self.logger.error(f"Error checking if user has been greeted: {e}")
return False
@@ -587,11 +683,11 @@ class GreeterCommand(BaseCommand):
count = cursor.fetchone()[0]
if count > 1:
# Duplicates exist - clean them up, keeping the earliest (first) greeting
- cursor.execute('''
- SELECT id FROM greeted_users
- WHERE sender_id = ? AND channel = ?
+ cursor.execute('''
+ SELECT id FROM greeted_users
+ WHERE sender_id = ? AND channel = ?
ORDER BY greeted_at ASC
- ''', (sender_id, channel))
+ ''', (sender_id, channel))
all_ids = [row[0] for row in cursor.fetchall()]
if len(all_ids) > 1:
# Delete all but the first (earliest)
@@ -639,11 +735,11 @@ class GreeterCommand(BaseCommand):
count = cursor.fetchone()[0]
if count > 1:
# Duplicates exist - clean them up, keeping the earliest (first) greeting
- cursor.execute('''
- SELECT id FROM greeted_users
- WHERE sender_id = ? AND channel IS NULL
+ cursor.execute('''
+ SELECT id FROM greeted_users
+ WHERE sender_id = ? AND channel IS NULL
ORDER BY greeted_at ASC
- ''', (sender_id,))
+ ''', (sender_id,))
all_ids = [row[0] for row in cursor.fetchall()]
if len(all_ids) > 1:
# Delete all but the first (earliest)
@@ -956,6 +1052,182 @@ class GreeterCommand(BaseCommand):
self.logger.error(traceback.format_exc())
return False
+ def _check_human_greeting(self, new_user_id: str, channel: str, since_timestamp: int) -> bool:
+ """
+ Check if a human has greeted the new user by mentioning their name in a message
+
+ Args:
+ new_user_id: The new user's ID to check for
+ channel: The channel to check
+ since_timestamp: Only check messages after this timestamp
+
+ Returns:
+ True if a human (not the new user) has mentioned the new user's name
+ """
+ if not self.defer_to_human_greeting:
+ return False
+
+ try:
+ with sqlite3.connect(self.bot.db_manager.db_path) as conn:
+ cursor = conn.cursor()
+
+ # Check if message_stats table exists
+ cursor.execute('''
+ SELECT name FROM sqlite_master
+ WHERE type='table' AND name='message_stats'
+ ''')
+ if not cursor.fetchone():
+ return False
+
+ # Get recent messages from this channel since the new user posted
+ cursor.execute('''
+ SELECT sender_id, content
+ FROM message_stats
+ WHERE channel = ?
+ AND timestamp >= ?
+ AND is_dm = 0
+ AND sender_id != ?
+ ORDER BY timestamp DESC
+ ''', (channel, since_timestamp, new_user_id))
+
+ messages = cursor.fetchall()
+
+ # Check if any message contains the new user's name
+ new_user_id_lower = new_user_id.lower()
+ for sender_id, content in messages:
+ if content and new_user_id_lower in content.lower():
+ # Also check with Levenshtein distance if enabled
+ if self.levenshtein_distance > 0:
+ # Check if any word in the message is within Levenshtein distance
+ words = content.lower().split()
+ for word in words:
+ # Remove common punctuation
+ word = word.strip('.,!?;:()[]{}@')
+ distance = self._levenshtein_distance(new_user_id_lower, word)
+ if distance <= self.levenshtein_distance:
+ self.logger.info(f"Human greeting detected: {sender_id} mentioned {new_user_id} in channel {channel}")
+ return True
+ else:
+ # Simple substring match
+ self.logger.info(f"Human greeting detected: {sender_id} mentioned {new_user_id} in channel {channel}")
+ return True
+
+ return False
+ except Exception as e:
+ self.logger.error(f"Error checking for human greeting: {e}")
+ return False
+
+ def _cancel_pending_greeting(self, sender_id: str, channel: str):
+ """Cancel a pending greeting if it exists"""
+ key = (sender_id, channel)
+ if key in self.pending_greetings:
+ task = self.pending_greetings[key]
+ if not task.done():
+ task.cancel()
+ self.logger.info(f"Cancelled pending greeting for {sender_id} on {channel}")
+ del self.pending_greetings[key]
+
+ async def _send_delayed_greeting(self, message: MeshMessage):
+ """
+ Send a greeting after the dead air delay, checking for human greetings during the delay
+
+ Args:
+ message: The original message that triggered the greeting
+ """
+ key = (message.sender_id, message.channel)
+ original_timestamp = message.timestamp or int(time.time())
+
+ try:
+ # Wait for the dead air delay
+ if self.dead_air_delay_seconds > 0:
+ self.logger.debug(f"Waiting {self.dead_air_delay_seconds} seconds before greeting {message.sender_id} on {message.channel}")
+ await asyncio.sleep(self.dead_air_delay_seconds)
+
+ # Check if greeting was cancelled (user was already greeted or human responded)
+ if key not in self.pending_greetings:
+ self.logger.debug(f"Greeting for {message.sender_id} on {message.channel} was cancelled")
+ return
+
+ # Check if we should still greet (user might have been greeted by another process)
+ if self.has_been_greeted(message.sender_id, message.channel):
+ self.logger.debug(f"User {message.sender_id} already greeted on {message.channel} - skipping")
+ if key in self.pending_greetings:
+ del self.pending_greetings[key]
+ return
+
+ # If defer to human greeting is enabled, check if a human has greeted the user
+ # Check messages from the original timestamp onwards (during the delay period)
+ if self.defer_to_human_greeting and self.dead_air_delay_seconds > 0:
+ if self._check_human_greeting(message.sender_id, message.channel, original_timestamp):
+ self.logger.info(f"Deferring to human greeting for {message.sender_id} on {message.channel}")
+ # Mark as greeted so we don't greet them later
+ self.mark_as_greeted(message.sender_id, message.channel)
+ if key in self.pending_greetings:
+ del self.pending_greetings[key]
+ return
+
+ # Send the greeting
+ await self._send_greeting(message)
+
+ # Clean up
+ if key in self.pending_greetings:
+ del self.pending_greetings[key]
+
+ except asyncio.CancelledError:
+ self.logger.debug(f"Delayed greeting for {message.sender_id} on {message.channel} was cancelled")
+ # Clean up on cancellation
+ if key in self.pending_greetings:
+ del self.pending_greetings[key]
+ except Exception as e:
+ self.logger.error(f"Error in delayed greeting for {message.sender_id}: {e}")
+ if key in self.pending_greetings:
+ del self.pending_greetings[key]
+
+ async def _send_greeting(self, message: MeshMessage) -> bool:
+ """
+ Actually send the greeting message (extracted from execute for reuse)
+
+ Args:
+ message: The message that triggered the greeting
+
+ Returns:
+ True if greeting was sent successfully
+ """
+ try:
+ # Format greeting parts (may be single or multi-part)
+ # Pass channel name for channel-specific greetings
+ greeting_parts = await self._format_greeting_parts(message.sender_id, message.channel)
+
+ # Send greeting(s)
+ mode_str = "per-channel" if self.per_channel_greetings else "global"
+ self.logger.info(f"Greeting {message.sender_id} on channel {message.channel} ({mode_str} mode, {len(greeting_parts)} part(s))")
+
+ # Log database verification
+ total_greeted = self.get_greeted_users_count()
+ self.logger.debug(f"Database verification: {total_greeted} total user(s) marked as greeted")
+
+ # Send all greeting parts
+ success = True
+ for i, greeting_part in enumerate(greeting_parts):
+ if i > 0:
+ # Wait for bot TX rate limiter between multi-part messages
+ # This ensures we respect the bot's rate limiting configuration
+ await self.bot.bot_tx_rate_limiter.wait_for_tx()
+ # Additional delay to ensure proper spacing (use configured rate limit)
+ rate_limit = self.bot.config.getfloat('Bot', 'bot_tx_rate_limit_seconds', fallback=1.0)
+ # Use a conservative sleep time to avoid rate limiting
+ sleep_time = max(rate_limit + 0.5, 1.0) # At least 1 second, or rate_limit + 0.5 seconds
+ await asyncio.sleep(sleep_time)
+
+ result = await self.send_response(message, greeting_part)
+ if not result:
+ success = False
+
+ return success
+ except Exception as e:
+ self.logger.error(f"Error sending greeting: {e}")
+ return False
+
def should_execute(self, message: MeshMessage) -> bool:
"""
Check if greeter should execute for this message
@@ -1013,7 +1285,7 @@ class GreeterCommand(BaseCommand):
if not self.should_execute(message):
return False
- # Mark as greeted BEFORE getting mesh info (to prevent duplicate greetings)
+ # Mark as greeted BEFORE scheduling greeting (to prevent duplicate greetings)
# This ensures we don't greet the same user twice even if there's a delay
# mark_as_greeted uses atomic INSERT OR IGNORE to handle race conditions
marked = self.mark_as_greeted(message.sender_id, message.channel)
@@ -1061,42 +1333,71 @@ class GreeterCommand(BaseCommand):
# If check fails, proceed anyway (better to greet than miss a greeting)
self.logger.debug(f"Could not verify greeting timestamp (proceeding anyway): {e}")
- # Format greeting parts (may be single or multi-part)
- # Pass channel name for channel-specific greetings
- greeting_parts = await self._format_greeting_parts(message.sender_id, message.channel)
-
- # Send greeting(s)
- mode_str = "per-channel" if self.per_channel_greetings else "global"
- self.logger.info(f"Greeting {message.sender_id} on channel {message.channel} ({mode_str} mode, {len(greeting_parts)} part(s))")
-
- # Log database verification
- total_greeted = self.get_greeted_users_count()
- self.logger.debug(f"Database verification: {total_greeted} total user(s) marked as greeted")
-
- # Send all greeting parts
- success = True
- for i, greeting_part in enumerate(greeting_parts):
- if i > 0:
- # Wait for bot TX rate limiter between multi-part messages
- # This ensures we respect the bot's rate limiting configuration
- await self.bot.bot_tx_rate_limiter.wait_for_tx()
- # Additional delay to ensure proper spacing (use configured rate limit)
- import asyncio
- rate_limit = self.bot.config.getfloat('Bot', 'bot_tx_rate_limit_seconds', fallback=1.0)
- # Use a conservative sleep time to avoid rate limiting
- sleep_time = max(rate_limit + 0.5, 1.0) # At least 1 second, or rate_limit + 0.5 seconds
- await asyncio.sleep(sleep_time)
+ # Check if dead air delay is enabled
+ if self.dead_air_delay_seconds > 0:
+ # Schedule delayed greeting
+ key = (message.sender_id, message.channel)
- result = await self.send_response(message, greeting_part)
- if not result:
- success = False
-
- return success
+ # Cancel any existing pending greeting for this user/channel
+ if key in self.pending_greetings:
+ self._cancel_pending_greeting(message.sender_id, message.channel)
+
+ # Schedule new delayed greeting
+ task = asyncio.create_task(self._send_delayed_greeting(message))
+ self.pending_greetings[key] = task
+ self.logger.info(f"Scheduled delayed greeting for {message.sender_id} on {message.channel} (delay: {self.dead_air_delay_seconds}s)")
+ return True
+ else:
+ # Send greeting immediately (original behavior)
+ return await self._send_greeting(message)
except Exception as e:
self.logger.error(f"Error executing greeter command: {e}")
return False
+ def check_message_for_human_greeting(self, message: MeshMessage):
+ """
+ Check if an incoming message should cancel a pending greeting
+ Called from message handler when new messages arrive
+
+ Args:
+ message: The incoming message to check
+ """
+ if not self.defer_to_human_greeting or not self.dead_air_delay_seconds > 0:
+ return
+
+ if message.is_dm or not message.channel:
+ return
+
+ # Check all pending greetings for this channel
+ keys_to_cancel = []
+ for (sender_id, channel), task in list(self.pending_greetings.items()):
+ if channel == message.channel and sender_id != message.sender_id:
+ # Check if this message mentions the pending user
+ if message.content and sender_id.lower() in message.content.lower():
+ # Also check with Levenshtein distance if enabled
+ should_cancel = False
+ if self.levenshtein_distance > 0:
+ words = message.content.lower().split()
+ for word in words:
+ word = word.strip('.,!?;:()[]{}@')
+ distance = self._levenshtein_distance(sender_id.lower(), word)
+ if distance <= self.levenshtein_distance:
+ should_cancel = True
+ break
+ else:
+ should_cancel = True
+
+ if should_cancel:
+ self.logger.info(f"Human greeting detected in real-time: {message.sender_id} mentioned {sender_id} - cancelling pending greeting")
+ keys_to_cancel.append((sender_id, channel))
+
+ # Cancel the pending greetings
+ for key in keys_to_cancel:
+ self._cancel_pending_greeting(key[0], key[1])
+ # Mark as greeted so we don't greet them later
+ self.mark_as_greeted(key[0], key[1])
+
def get_help_text(self) -> str:
mode = "per-channel" if self.per_channel_greetings else "global (once total)"
return f"Greeter automatically welcomes new users on public channels ({mode} mode). Configure in [Greeter_Command] section."
diff --git a/modules/core.py b/modules/core.py
index 6d4ef80..d6f35c1 100644
--- a/modules/core.py
+++ b/modules/core.py
@@ -35,6 +35,7 @@ from .db_manager import DBManager
from .i18n import Translator
from .solar_conditions import set_config
from .web_viewer.integration import WebViewerIntegration
+from .feed_manager import FeedManager
class MeshCoreBot:
@@ -125,6 +126,15 @@ class MeshCoreBot:
self.scheduler = MessageScheduler(self)
+ # Initialize feed manager
+ self.logger.info("Initializing feed manager")
+ try:
+ self.feed_manager = FeedManager(self)
+ self.logger.info("Feed manager initialized successfully")
+ except Exception as e:
+ self.logger.warning(f"Failed to initialize feed manager: {e}")
+ self.feed_manager = None
+
# Initialize repeater manager
self.logger.info("Initializing repeater manager")
try:
@@ -743,6 +753,10 @@ use_zulu_time = false
# Setup scheduled messages
self.scheduler.setup_scheduled_messages()
+ # Initialize feed manager (if enabled)
+ if self.feed_manager:
+ await self.feed_manager.initialize()
+
# Start scheduler thread
self.scheduler.start()
@@ -794,6 +808,10 @@ use_zulu_time = false
self.connected = False
+ # Stop feed manager
+ if self.feed_manager:
+ await self.feed_manager.stop()
+
# Stop web viewer with proper shutdown sequence
if self.web_viewer_integration:
# Web viewer has simpler shutdown
diff --git a/modules/db_manager.py b/modules/db_manager.py
index 57db433..5e65ed3 100644
--- a/modules/db_manager.py
+++ b/modules/db_manager.py
@@ -59,12 +59,135 @@ class DBManager:
)
''')
+ # Create feed_subscriptions table for RSS/API feed subscriptions
+ cursor.execute('''
+ CREATE TABLE IF NOT EXISTS feed_subscriptions (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ feed_type TEXT NOT NULL,
+ feed_url TEXT NOT NULL,
+ channel_name TEXT NOT NULL,
+ feed_name TEXT,
+ last_item_id TEXT,
+ last_check_time TIMESTAMP,
+ check_interval_seconds INTEGER DEFAULT 300,
+ enabled BOOLEAN DEFAULT 1,
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
+ updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
+ api_config TEXT,
+ rss_config TEXT,
+ output_format TEXT,
+ message_send_interval_seconds REAL DEFAULT 2.0,
+ UNIQUE(feed_url, channel_name)
+ )
+ ''')
+
+ # Add new columns if they don't exist (for existing databases)
+ try:
+ cursor.execute('ALTER TABLE feed_subscriptions ADD COLUMN output_format TEXT')
+ except sqlite3.OperationalError:
+ pass # Column already exists
+ try:
+ cursor.execute('ALTER TABLE feed_subscriptions ADD COLUMN message_send_interval_seconds REAL DEFAULT 2.0')
+ except sqlite3.OperationalError:
+ pass # Column already exists
+ try:
+ cursor.execute('ALTER TABLE feed_subscriptions ADD COLUMN filter_config TEXT')
+ except sqlite3.OperationalError:
+ pass # Column already exists
+ try:
+ cursor.execute('ALTER TABLE feed_subscriptions ADD COLUMN sort_config TEXT')
+ except sqlite3.OperationalError:
+ pass # Column already exists
+
+ # Create feed_activity table for tracking processed items
+ cursor.execute('''
+ CREATE TABLE IF NOT EXISTS feed_activity (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ feed_id INTEGER NOT NULL,
+ item_id TEXT NOT NULL,
+ item_title TEXT,
+ processed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
+ message_sent BOOLEAN DEFAULT 1,
+ FOREIGN KEY (feed_id) REFERENCES feed_subscriptions(id) ON DELETE CASCADE
+ )
+ ''')
+
+ # Create feed_errors table for tracking feed errors
+ cursor.execute('''
+ CREATE TABLE IF NOT EXISTS feed_errors (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ feed_id INTEGER NOT NULL,
+ error_type TEXT NOT NULL,
+ error_message TEXT,
+ occurred_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
+ resolved_at TIMESTAMP,
+ FOREIGN KEY (feed_id) REFERENCES feed_subscriptions(id) ON DELETE CASCADE
+ )
+ ''')
+
+ # Create channels table for storing channel information
+ cursor.execute('''
+ CREATE TABLE IF NOT EXISTS channels (
+ channel_idx INTEGER PRIMARY KEY,
+ channel_name TEXT NOT NULL,
+ channel_type TEXT,
+ channel_key_hex TEXT,
+ last_updated TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
+ UNIQUE(channel_idx)
+ )
+ ''')
+
+ # Create channel_operations queue table for web viewer -> bot communication
+ cursor.execute('''
+ CREATE TABLE IF NOT EXISTS channel_operations (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ operation_type TEXT NOT NULL,
+ channel_idx INTEGER,
+ channel_name TEXT,
+ channel_key_hex TEXT,
+ status TEXT DEFAULT 'pending',
+ error_message TEXT,
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
+ processed_at TIMESTAMP,
+ result_data TEXT
+ )
+ ''')
+
+ # Create feed_message_queue table for queuing feed messages
+ cursor.execute('''
+ CREATE TABLE IF NOT EXISTS feed_message_queue (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ feed_id INTEGER NOT NULL,
+ channel_name TEXT NOT NULL,
+ message TEXT NOT NULL,
+ item_id TEXT,
+ item_title TEXT,
+ priority INTEGER DEFAULT 0,
+ queued_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
+ sent_at TIMESTAMP,
+ FOREIGN KEY (feed_id) REFERENCES feed_subscriptions(id) ON DELETE CASCADE
+ )
+ ''')
+
# Create indexes for better performance
cursor.execute('CREATE INDEX IF NOT EXISTS idx_geocoding_query ON geocoding_cache(query)')
cursor.execute('CREATE INDEX IF NOT EXISTS idx_geocoding_expires ON geocoding_cache(expires_at)')
cursor.execute('CREATE INDEX IF NOT EXISTS idx_generic_key ON generic_cache(cache_key)')
cursor.execute('CREATE INDEX IF NOT EXISTS idx_generic_type ON generic_cache(cache_type)')
cursor.execute('CREATE INDEX IF NOT EXISTS idx_generic_expires ON generic_cache(expires_at)')
+ cursor.execute('CREATE INDEX IF NOT EXISTS idx_feed_subscriptions_enabled ON feed_subscriptions(enabled)')
+ cursor.execute('CREATE INDEX IF NOT EXISTS idx_feed_subscriptions_type ON feed_subscriptions(feed_type)')
+ cursor.execute('CREATE INDEX IF NOT EXISTS idx_feed_subscriptions_last_check ON feed_subscriptions(last_check_time)')
+ cursor.execute('CREATE INDEX IF NOT EXISTS idx_feed_activity_feed_id ON feed_activity(feed_id)')
+ cursor.execute('CREATE INDEX IF NOT EXISTS idx_feed_activity_processed_at ON feed_activity(processed_at)')
+ cursor.execute('CREATE INDEX IF NOT EXISTS idx_feed_errors_feed_id ON feed_errors(feed_id)')
+ cursor.execute('CREATE INDEX IF NOT EXISTS idx_feed_errors_occurred_at ON feed_errors(occurred_at)')
+ cursor.execute('CREATE INDEX IF NOT EXISTS idx_feed_errors_resolved ON feed_errors(resolved_at)')
+ cursor.execute('CREATE INDEX IF NOT EXISTS idx_channels_name ON channels(channel_name)')
+ cursor.execute('CREATE INDEX IF NOT EXISTS idx_channel_ops_status ON channel_operations(status, created_at)')
+ cursor.execute('CREATE INDEX IF NOT EXISTS idx_feed_message_queue_feed_id ON feed_message_queue(feed_id)')
+ cursor.execute('CREATE INDEX IF NOT EXISTS idx_feed_message_queue_sent ON feed_message_queue(sent_at)')
+ cursor.execute('CREATE INDEX IF NOT EXISTS idx_feed_message_queue_priority ON feed_message_queue(priority DESC, queued_at ASC)')
conn.commit()
self.logger.info("Database manager initialized successfully")
diff --git a/modules/feed_manager.py b/modules/feed_manager.py
new file mode 100644
index 0000000..fcf02ed
--- /dev/null
+++ b/modules/feed_manager.py
@@ -0,0 +1,1252 @@
+#!/usr/bin/env python3
+"""
+Feed Manager for RSS and API feed subscriptions
+Handles polling feeds and sending updates to channels
+"""
+
+import asyncio
+import aiohttp
+import json
+import time
+import hashlib
+import html
+import re
+from datetime import datetime, timezone
+from typing import Dict, List, Optional, Any, Tuple
+import sqlite3
+import feedparser
+from urllib.parse import urlparse
+
+
+class FeedManager:
+ """Manages RSS and API feed subscriptions"""
+
+ def __init__(self, bot):
+ self.bot = bot
+ self.logger = bot.logger
+ self.db_path = bot.db_manager.db_path
+
+ # Configuration
+ self.enabled = bot.config.getboolean('Feed_Manager', 'feed_manager_enabled', fallback=False)
+ self.default_check_interval = bot.config.getint('Feed_Manager', 'default_check_interval_seconds', fallback=300)
+ self.max_items_per_check = bot.config.getint('Feed_Manager', 'max_items_per_check', fallback=10)
+ self.request_timeout = bot.config.getint('Feed_Manager', 'feed_request_timeout', fallback=30)
+ self.user_agent = bot.config.get('Feed_Manager', 'feed_user_agent', fallback='MeshCoreBot/1.0 FeedManager')
+ self.rate_limit_seconds = bot.config.getfloat('Feed_Manager', 'feed_rate_limit_seconds', fallback=5.0)
+ self.max_message_length = bot.config.getint('Feed_Manager', 'max_message_length', fallback=130)
+ self.default_output_format = bot.config.get('Feed_Manager', 'default_output_format', fallback='{emoji} {body|truncate:100} - {date}\n{link|truncate:50}')
+ self.default_send_interval = bot.config.getfloat('Feed_Manager', 'default_message_send_interval_seconds', fallback=2.0)
+
+ # Rate limiting per domain
+ self._domain_last_request: Dict[str, float] = {}
+
+ # HTTP session
+ self.session: Optional[aiohttp.ClientSession] = None
+
+ # Semaphore to limit concurrent requests
+ self._request_semaphore = asyncio.Semaphore(5)
+
+ self.logger.info("FeedManager initialized")
+
+ async def initialize(self):
+ """Initialize the feed manager (create HTTP session)"""
+ if not self.enabled:
+ self.logger.info("FeedManager is disabled in config")
+ return
+
+ # Don't create session here - create it lazily when needed
+ # This avoids issues with using sessions across different event loops
+ # The session will be created in the same event loop where it's used
+ self.logger.info("FeedManager initialized (session will be created on first use)")
+
+ async def stop(self):
+ """Stop the feed manager (close HTTP session)"""
+ if self.session and not self.session.closed:
+ await self.session.close()
+ self.session = None
+ self.logger.info("FeedManager stopped")
+
+ async def poll_all_feeds(self):
+ """Poll all enabled feeds that are due for checking"""
+ if not self.enabled:
+ return
+
+ try:
+ # Get all enabled feeds
+ feeds = self._get_enabled_feeds()
+
+ if not feeds:
+ return
+
+ # Filter feeds that are due for checking
+ current_time = time.time()
+ feeds_to_check = []
+
+ for feed in feeds:
+ last_check = feed.get('last_check_time')
+ if last_check:
+ try:
+ # Parse timestamp - handle both ISO format and SQLite format
+ if isinstance(last_check, str):
+ # Try ISO format first (with timezone)
+ try:
+ last_check_dt = datetime.fromisoformat(last_check.replace('Z', '+00:00'))
+ except ValueError:
+ # Try SQLite format (YYYY-MM-DD HH:MM:SS) - treat as UTC
+ try:
+ last_check_dt = datetime.strptime(last_check, '%Y-%m-%d %H:%M:%S')
+ last_check_dt = last_check_dt.replace(tzinfo=timezone.utc)
+ except ValueError:
+ # Try with microseconds
+ try:
+ last_check_dt = datetime.strptime(last_check, '%Y-%m-%d %H:%M:%S.%f')
+ last_check_dt = last_check_dt.replace(tzinfo=timezone.utc)
+ except ValueError:
+ raise ValueError(f"Unknown timestamp format: {last_check}")
+ else:
+ last_check_dt = datetime.fromtimestamp(last_check, tz=timezone.utc)
+
+ # Convert to timestamp
+ if last_check_dt.tzinfo:
+ last_check_ts = last_check_dt.timestamp()
+ else:
+ # Assume UTC if no timezone
+ last_check_ts = last_check_dt.replace(tzinfo=timezone.utc).timestamp()
+ except Exception as e:
+ self.logger.debug(f"Error parsing last_check_time for feed {feed['id']}: {e}")
+ last_check_ts = 0
+ else:
+ last_check_ts = 0
+
+ interval = feed.get('check_interval_seconds', self.default_check_interval)
+
+ if current_time - last_check_ts >= interval:
+ feeds_to_check.append(feed)
+
+ if not feeds_to_check:
+ self.logger.debug("No feeds due for checking at this time")
+ return
+
+ self.logger.info(f"Polling {len(feeds_to_check)} feed(s) that are due for checking")
+
+ # Poll feeds in parallel (with semaphore limit)
+ tasks = [self.poll_feed(feed) for feed in feeds_to_check]
+ await asyncio.gather(*tasks, return_exceptions=True)
+
+ except Exception as e:
+ self.logger.error(f"Error in poll_all_feeds: {e}")
+
+ async def _ensure_session(self):
+ """Ensure HTTP session exists in the current event loop"""
+ if self.session is None or self.session.closed:
+ # Create session in the current event loop context
+ self.session = aiohttp.ClientSession(
+ headers={'User-Agent': self.user_agent}
+ )
+ self.logger.debug("Created FeedManager HTTP session in current event loop")
+
+ async def poll_feed(self, feed: Dict[str, Any]):
+ """Poll a single feed and process new items"""
+ # Ensure session exists in current event loop
+ await self._ensure_session()
+
+ feed_id = feed['id']
+ feed_type = feed['feed_type']
+ feed_url = feed['feed_url']
+ channel_name = feed['channel_name']
+
+ try:
+ self.logger.debug(f"Polling {feed_type} feed {feed_id}: {feed_url}")
+
+ # Rate limit per domain
+ domain = urlparse(feed_url).netloc
+ await self._wait_for_rate_limit(domain)
+
+ # Fetch feed data
+ if feed_type == 'rss':
+ new_items = await self.process_rss_feed(feed)
+ elif feed_type == 'api':
+ new_items = await self.process_api_feed(feed)
+ else:
+ self.logger.warning(f"Unknown feed type: {feed_type}")
+ return
+
+ # Process new items
+ if new_items:
+ self.logger.info(f"Found {len(new_items)} new items for feed {feed_id}")
+ filtered_count = 0
+ for item in new_items[:self.max_items_per_check]:
+ # Check if item passes filter conditions
+ if self._should_send_item(feed, item):
+ await self._send_feed_item(feed, item)
+ else:
+ filtered_count += 1
+ self.logger.debug(f"Filtered out item: {item.get('title', 'Untitled')[:50]}")
+
+ if filtered_count > 0:
+ self.logger.debug(f"Filtered out {filtered_count} items for feed {feed_id}")
+ else:
+ self.logger.debug(f"No new items found for feed {feed_id}")
+
+ # Always update last check time, even if no new items
+ self._update_feed_last_check(feed_id)
+
+ except Exception as e:
+ self.logger.error(f"Error polling feed {feed_id}: {e}")
+ self._record_feed_error(feed_id, 'network', str(e))
+
+ async def process_rss_feed(self, feed: Dict[str, Any]) -> List[Dict[str, Any]]:
+ """Process an RSS feed and return new items"""
+ feed_url = feed['feed_url']
+ last_item_id = feed.get('last_item_id')
+
+ try:
+ # Fetch RSS feed - use aiohttp's timeout directly
+ # Create timeout object in the current async context
+ timeout = aiohttp.ClientTimeout(total=self.request_timeout)
+
+ async with self._request_semaphore:
+ try:
+ async with self.session.get(feed_url, timeout=timeout) as response:
+ if response.status != 200:
+ raise Exception(f"HTTP {response.status}")
+ content = await response.text()
+ except (asyncio.TimeoutError, aiohttp.ServerTimeoutError):
+ raise Exception(f"Request timeout after {self.request_timeout} seconds")
+
+ # Parse RSS feed
+ parsed = feedparser.parse(content)
+
+ if parsed.bozo:
+ self.logger.warning(f"RSS feed parsing warning: {parsed.bozo_exception}")
+
+ # Extract items - collect ALL items first (don't break early if sorting is configured)
+ all_items = []
+ for entry in parsed.entries:
+ # Get item ID (prefer guid, then link, then hash of title+link)
+ item_id = entry.get('id') or entry.get('guid') or entry.get('link')
+ if not item_id:
+ # Generate ID from title and link
+ item_id = hashlib.md5(
+ f"{entry.get('title', '')}{entry.get('link', '')}".encode()
+ ).hexdigest()
+
+ # Parse published date
+ published = None
+ if hasattr(entry, 'published_parsed') and entry.published_parsed:
+ try:
+ published = datetime(*entry.published_parsed[:6], tzinfo=timezone.utc)
+ except Exception:
+ pass
+
+ all_items.append({
+ 'id': item_id,
+ 'title': entry.get('title', 'Untitled'),
+ 'link': entry.get('link', ''),
+ 'description': entry.get('description', ''),
+ 'published': published
+ })
+
+ # Apply sorting if configured (before filtering, so we can properly track the last item)
+ sort_config_str = feed.get('sort_config')
+ if sort_config_str:
+ try:
+ sort_config = json.loads(sort_config_str) if isinstance(sort_config_str, str) else sort_config_str
+ all_items = self._sort_items(all_items, sort_config)
+ except (json.JSONDecodeError, TypeError, Exception) as e:
+ self.logger.warning(f"Error applying sort config for feed {feed['id']}: {e}")
+
+ # Reverse to get oldest first (if no sort config)
+ if not sort_config_str:
+ all_items.reverse()
+
+ # Now filter out items that have already been processed
+ # Check against both last_item_id and the feed_activity table for robust deduplication
+ items = []
+ processed_item_ids = set()
+
+ # Get all previously processed item IDs from feed_activity table
+ if last_item_id:
+ processed_item_ids.add(last_item_id)
+
+ # Query database for all processed item IDs for this feed
+ try:
+ with sqlite3.connect(self.db_path) as conn:
+ cursor = conn.cursor()
+ cursor.execute('''
+ SELECT DISTINCT item_id FROM feed_activity
+ WHERE feed_id = ?
+ ''', (feed['id'],))
+ for row in cursor.fetchall():
+ processed_item_ids.add(row[0])
+ except Exception as e:
+ self.logger.debug(f"Error querying processed items for feed {feed['id']}: {e}")
+
+ # Filter out already processed items
+ for item in all_items:
+ if item['id'] not in processed_item_ids:
+ items.append(item)
+ else:
+ self.logger.debug(f"Skipping already processed item {item['id']} for feed {feed['id']}")
+
+ # Update last_item_id if we have new items (use the last item from the sorted list)
+ if items:
+ # Use the last item from the original sorted list (all_items), not the filtered list
+ # This ensures we track the most recent item even if it was already processed
+ self._update_feed_last_item_id(feed['id'], all_items[-1]['id'])
+
+ return items
+
+ except Exception as e:
+ self.logger.error(f"Error processing RSS feed: {e}")
+ raise
+
+ async def process_api_feed(self, feed: Dict[str, Any]) -> List[Dict[str, Any]]:
+ """Process an API feed and return new items"""
+ feed_url = feed['feed_url']
+ api_config_str = feed.get('api_config', '{}')
+ last_item_id = feed.get('last_item_id')
+
+ try:
+ # Parse API config
+ api_config = json.loads(api_config_str) if api_config_str else {}
+
+ method = api_config.get('method', 'GET').upper()
+ headers = api_config.get('headers', {})
+ params = api_config.get('params', {})
+ body = api_config.get('body')
+ parser_config = api_config.get('response_parser', {})
+
+ # Make HTTP request - use aiohttp's timeout directly
+ # Create timeout object in the current async context
+ timeout = aiohttp.ClientTimeout(total=self.request_timeout)
+
+ async with self._request_semaphore:
+ try:
+ if method == 'POST':
+ async with self.session.post(feed_url, headers=headers, params=params, json=body, timeout=timeout) as response:
+ if response.status != 200:
+ raise Exception(f"HTTP {response.status}")
+ data = await response.json()
+ else:
+ async with self.session.get(feed_url, headers=headers, params=params, timeout=timeout) as response:
+ if response.status != 200:
+ raise Exception(f"HTTP {response.status}")
+ data = await response.json()
+ except (asyncio.TimeoutError, aiohttp.ServerTimeoutError):
+ raise Exception(f"Request timeout after {self.request_timeout} seconds")
+
+ # Extract items using parser config
+ items_path = parser_config.get('items_path', '')
+ if items_path:
+ # Navigate JSON path
+ parts = items_path.split('.')
+ items_data = data
+ for part in parts:
+ items_data = items_data.get(part, [])
+ else:
+ # Assume data is a list
+ items_data = data if isinstance(data, list) else [data]
+
+ # Extract items
+ id_field = parser_config.get('id_field', 'id')
+ title_field = parser_config.get('title_field', 'title')
+ description_field = parser_config.get('description_field', 'description') # New: allow custom description field
+ timestamp_field = parser_config.get('timestamp_field', 'created_at')
+
+ # Collect ALL items first (don't break early, as sorting may reorder them)
+ all_items = []
+ for item_data in items_data:
+ item_id = str(self._get_nested_value(item_data, id_field, ''))
+ if not item_id:
+ continue
+
+ # Parse timestamp if available - support nested paths
+ published = None
+ if timestamp_field:
+ ts_value = self._get_nested_value(item_data, timestamp_field)
+ if ts_value:
+ try:
+ if isinstance(ts_value, (int, float)):
+ published = datetime.fromtimestamp(ts_value, tz=timezone.utc)
+ elif isinstance(ts_value, str):
+ # Try Microsoft date format first
+ if ts_value.startswith('/Date('):
+ published = self._parse_microsoft_date(ts_value)
+ else:
+ # Try ISO format
+ try:
+ published = datetime.fromisoformat(ts_value.replace('Z', '+00:00'))
+ except ValueError:
+ # Try common formats
+ for fmt in ['%Y-%m-%dT%H:%M:%S', '%Y-%m-%d %H:%M:%S', '%Y-%m-%d']:
+ try:
+ published = datetime.strptime(ts_value, fmt)
+ if published.tzinfo is None:
+ published = published.replace(tzinfo=timezone.utc)
+ break
+ except ValueError:
+ continue
+ except Exception:
+ pass
+
+ # Get description - support nested paths
+ description = ''
+ if description_field:
+ desc_value = self._get_nested_value(item_data, description_field)
+ if desc_value:
+ description = str(desc_value)
+
+ all_items.append({
+ 'id': item_id,
+ 'title': self._get_nested_value(item_data, title_field, 'Untitled'),
+ 'link': item_data.get('link', ''),
+ 'description': description,
+ 'published': published,
+ 'raw': item_data # Store full raw response for field access
+ })
+
+ # Apply sorting if configured (before filtering, so we can properly track the last item)
+ sort_config_str = feed.get('sort_config')
+ if sort_config_str:
+ try:
+ sort_config = json.loads(sort_config_str) if isinstance(sort_config_str, str) else sort_config_str
+ all_items = self._sort_items(all_items, sort_config)
+ except (json.JSONDecodeError, TypeError, Exception) as e:
+ self.logger.warning(f"Error applying sort config for feed {feed['id']}: {e}")
+
+ # Reverse to get oldest first (if no sort config)
+ if not sort_config_str:
+ all_items.reverse()
+
+ # Now filter out items that have already been processed
+ # Check against both last_item_id and the feed_activity table for robust deduplication
+ items = []
+ processed_item_ids = set()
+
+ # Get all previously processed item IDs from feed_activity table
+ if last_item_id:
+ processed_item_ids.add(last_item_id)
+
+ # Query database for all processed item IDs for this feed
+ try:
+ with sqlite3.connect(self.db_path) as conn:
+ cursor = conn.cursor()
+ cursor.execute('''
+ SELECT DISTINCT item_id FROM feed_activity
+ WHERE feed_id = ?
+ ''', (feed['id'],))
+ for row in cursor.fetchall():
+ processed_item_ids.add(row[0])
+ except Exception as e:
+ self.logger.debug(f"Error querying processed items for feed {feed['id']}: {e}")
+
+ # Filter out already processed items
+ for item in all_items:
+ if item['id'] not in processed_item_ids:
+ items.append(item)
+ else:
+ self.logger.debug(f"Skipping already processed item {item['id']} for feed {feed['id']}")
+
+ # Update last_item_id if we have new items (use the last item from the sorted list)
+ if items:
+ # Use the last item from the original sorted list (all_items), not the filtered list
+ # This ensures we track the most recent item even if it was already processed
+ self._update_feed_last_item_id(feed['id'], all_items[-1]['id'])
+
+ return items
+
+ except Exception as e:
+ self.logger.error(f"Error processing API feed: {e}")
+ raise
+
+ def _format_timestamp(self, published: Optional[datetime]) -> str:
+ """Format a timestamp as a relative time string"""
+ if not published:
+ return ""
+
+ try:
+ if published.tzinfo:
+ now = datetime.now(timezone.utc)
+ else:
+ now = datetime.now()
+
+ diff = now - published
+ minutes = int(diff.total_seconds() / 60)
+
+ if minutes < 1:
+ return "now"
+ elif minutes < 60:
+ return f"{minutes}m ago"
+ elif minutes < 1440:
+ hours = minutes // 60
+ mins = minutes % 60
+ return f"{hours}h {mins}m ago"
+ else:
+ days = minutes // 1440
+ return f"{days}d ago"
+ except Exception:
+ return ""
+
+ def _apply_shortening(self, text: str, function: str) -> str:
+ """Apply a shortening, parsing, or conditional function to text
+
+ Supported functions:
+ - truncate:N - truncate to N characters
+ - word_wrap:N - wrap at N characters, breaking at word boundaries
+ - first_words:N - take first N words
+ - regex:pattern - extract using regex pattern (uses first capture group, or whole match)
+ - regex:pattern:group - extract specific capture group (0 = whole match, 1 = first group, etc.)
+ - if_regex:pattern:then:else - if pattern matches, return "then", else return "else"
+ """
+ if not text:
+ return ""
+
+ if function.startswith('truncate:'):
+ try:
+ max_len = int(function.split(':', 1)[1])
+ if len(text) <= max_len:
+ return text
+ return text[:max_len] + "..."
+ except (ValueError, IndexError):
+ return text
+
+ elif function.startswith('word_wrap:'):
+ try:
+ max_len = int(function.split(':', 1)[1])
+ if len(text) <= max_len:
+ return text
+ # Find last space before max_len
+ truncated = text[:max_len]
+ last_space = truncated.rfind(' ')
+ if last_space > max_len * 0.7: # Only use word boundary if it's not too short
+ return truncated[:last_space] + "..."
+ return truncated + "..."
+ except (ValueError, IndexError):
+ return text
+
+ elif function.startswith('first_words:'):
+ try:
+ num_words = int(function.split(':', 1)[1])
+ words = text.split()
+ if len(words) <= num_words:
+ return text
+ return ' '.join(words[:num_words]) + "..."
+ except (ValueError, IndexError):
+ return text
+
+ elif function.startswith('regex:'):
+ try:
+ # Parse regex pattern and optional group number
+ # Format: regex:pattern:group or regex:pattern
+ # Need to handle patterns that contain colons, so split from the right
+ remaining = function[6:] # Skip 'regex:' prefix
+
+ # Try to find the last colon that's followed by a number (the group number)
+ # Look for pattern like :N at the end
+ last_colon_idx = remaining.rfind(':')
+ pattern = remaining
+ group_num = None
+
+ if last_colon_idx > 0:
+ # Check if what's after the last colon is a number
+ potential_group = remaining[last_colon_idx + 1:]
+ if potential_group.isdigit():
+ pattern = remaining[:last_colon_idx]
+ group_num = int(potential_group)
+
+ if not pattern:
+ return text
+
+ # Apply regex
+ match = re.search(pattern, text, re.IGNORECASE | re.DOTALL)
+ if match:
+ if group_num is not None:
+ # Use specified group (0 = whole match, 1 = first group, etc.)
+ if 0 <= group_num <= len(match.groups()):
+ return match.group(group_num) if group_num > 0 else match.group(0)
+ else:
+ # Use first capture group if available, otherwise whole match
+ if match.groups():
+ return match.group(1)
+ else:
+ return match.group(0)
+ return "" # No match found
+ except (ValueError, IndexError, re.error) as e:
+ self.logger.debug(f"Error applying regex function: {e}")
+ return text
+
+ elif function.startswith('if_regex:'):
+ try:
+ # Parse: if_regex:pattern:then:else
+ # Split by ':' but need to handle regex patterns that contain ':'
+ # Use a smarter split that respects the structure
+ parts = function[9:].split(':', 2) # Skip 'if_regex:' prefix, split into [pattern, then, else]
+ if len(parts) < 3:
+ return text
+
+ pattern = parts[0]
+ then_value = parts[1]
+ else_value = parts[2]
+
+ if not pattern:
+ return text
+
+ # Check if pattern matches
+ match = re.search(pattern, text, re.IGNORECASE | re.DOTALL)
+ if match:
+ return then_value
+ else:
+ return else_value
+ except (ValueError, IndexError, re.error) as e:
+ self.logger.debug(f"Error applying if_regex function: {e}")
+ return text
+
+ elif function.startswith('switch:'):
+ try:
+ # Parse: switch:value1:result1:value2:result2:...:default
+ # Example: switch:highest:đ´:high:đ :medium:đĄ:low:âĒ:âĒ
+ # This checks if text exactly matches value1, returns result1, etc., or default
+ parts = function[7:].split(':') # Skip 'switch:' prefix
+ if len(parts) < 2:
+ return text
+
+ # Pairs of value:result, last one is default
+ text_lower = text.lower().strip()
+ for i in range(0, len(parts) - 1, 2):
+ if i + 1 < len(parts):
+ value = parts[i].lower()
+ result = parts[i + 1]
+ if text_lower == value:
+ return result
+
+ # Return last part as default if no match
+ return parts[-1] if parts else text
+ except (ValueError, IndexError) as e:
+ self.logger.debug(f"Error applying switch function: {e}")
+ return text
+
+ elif function.startswith('regex_cond:'):
+ try:
+ # Parse: regex_cond:extract_pattern:check_pattern:then:group
+ # This extracts text using extract_pattern, then checks if it matches check_pattern
+ # If check_pattern matches, return "then", else return the extracted text
+ # Example: regex_cond:Northbound\s*\n([^\n]+):No restrictions:đ:1
+ # This extracts text after "Northbound\n" up to next newline, checks if it's "No restrictions",
+ # if yes returns "đ", else returns the extracted text
+ parts = function[11:].split(':', 3) # Skip 'regex_cond:' prefix
+ if len(parts) < 4:
+ return text
+
+ extract_pattern = parts[0]
+ check_pattern = parts[1]
+ then_value = parts[2]
+ else_group = int(parts[3]) if parts[3].isdigit() else 1
+
+ if not extract_pattern:
+ return text
+
+ # Extract using extract_pattern
+ match = re.search(extract_pattern, text, re.IGNORECASE | re.DOTALL)
+ if match:
+ # Get the captured group
+ if match.groups():
+ extracted = match.group(else_group) if else_group <= len(match.groups()) else match.group(1)
+ # Strip whitespace from extracted text
+ extracted = extracted.strip()
+ else:
+ extracted = match.group(0).strip()
+
+ # Check if extracted text matches check_pattern (exact match or contains)
+ if check_pattern:
+ # Try exact match first, then substring match
+ if extracted.lower() == check_pattern.lower() or re.search(check_pattern, extracted, re.IGNORECASE):
+ return then_value
+
+ return extracted
+ return "" # No match found
+ except (ValueError, IndexError, re.error) as e:
+ self.logger.debug(f"Error applying regex_cond function: {e}")
+ return text
+
+ return text
+
+ def _get_nested_value(self, data: Any, path: str, default: Any = '') -> Any:
+ """Get a nested value from a dict/list using dot notation (e.g., 'raw.Priority' or 'raw.StartRoadwayLocation.RoadName')"""
+ if not path or not data:
+ return default
+
+ parts = path.split('.')
+ value = data
+
+ for part in parts:
+ if isinstance(value, dict):
+ value = value.get(part)
+ elif isinstance(value, list):
+ try:
+ idx = int(part)
+ if 0 <= idx < len(value):
+ value = value[idx]
+ else:
+ return default
+ except (ValueError, TypeError):
+ return default
+ else:
+ return default
+
+ if value is None:
+ return default
+
+ return value if value is not None else default
+
+ def _parse_microsoft_date(self, date_str: str) -> Optional[datetime]:
+ """Parse Microsoft JSON date format: /Date(timestamp-offset)/"""
+ if not date_str or not isinstance(date_str, str):
+ return None
+
+ # Match /Date(timestamp-offset)/ format
+ match = re.match(r'/Date\((\d+)([+-]\d+)?\)/', date_str)
+ if match:
+ timestamp_ms = int(match.group(1))
+ offset_str = match.group(2) if match.group(2) else '+0000'
+
+ # Convert milliseconds to seconds
+ timestamp = timestamp_ms / 1000.0
+
+ # Parse offset (format: +0800 or -0800)
+ try:
+ offset_hours = int(offset_str[:3])
+ offset_mins = int(offset_str[3:5])
+ offset_seconds = (offset_hours * 3600) + (offset_mins * 60)
+ if offset_str[0] == '-':
+ offset_seconds = -offset_seconds
+
+ # Create timezone-aware datetime
+ tz = timezone.utc
+ if offset_seconds != 0:
+ from datetime import timedelta
+ tz = timezone(timedelta(seconds=offset_seconds))
+
+ return datetime.fromtimestamp(timestamp, tz=tz)
+ except (ValueError, IndexError):
+ # Fallback to UTC if offset parsing fails
+ return datetime.fromtimestamp(timestamp, tz=timezone.utc)
+
+ return None
+
+ def _sort_items(self, items: List[Dict[str, Any]], sort_config: dict) -> List[Dict[str, Any]]:
+ """Sort items based on sort configuration
+
+ Sort config format:
+ {
+ "field": "raw.LastUpdatedTime", # Field path to sort by
+ "order": "desc" # "asc" or "desc"
+ }
+ """
+ if not sort_config or not items:
+ return items
+
+ field_path = sort_config.get('field')
+ order = sort_config.get('order', 'desc').lower()
+
+ if not field_path:
+ return items
+
+ def get_sort_value(item):
+ """Get the sort value for an item"""
+ # Try raw data first
+ raw_data = item.get('raw', {})
+ value = self._get_nested_value(raw_data, field_path, '')
+
+ if not value and field_path.startswith('raw.'):
+ value = self._get_nested_value(raw_data, field_path[4:], '')
+
+ if not value:
+ value = self._get_nested_value(item, field_path, '')
+
+ # Handle Microsoft date format
+ if isinstance(value, str) and value.startswith('/Date('):
+ dt = self._parse_microsoft_date(value)
+ if dt:
+ return dt.timestamp()
+
+ # Handle datetime objects
+ if isinstance(value, datetime):
+ return value.timestamp()
+
+ # Handle numeric values
+ if isinstance(value, (int, float)):
+ return float(value)
+
+ # Handle string timestamps
+ if isinstance(value, str):
+ # Try to parse as ISO format
+ try:
+ dt = datetime.fromisoformat(value.replace('Z', '+00:00'))
+ return dt.timestamp()
+ except ValueError:
+ pass
+
+ # Try common date formats
+ for fmt in ['%Y-%m-%dT%H:%M:%S', '%Y-%m-%d %H:%M:%S', '%Y-%m-%d']:
+ try:
+ dt = datetime.strptime(value, fmt)
+ return dt.timestamp()
+ except ValueError:
+ continue
+
+ # For strings, use lexicographic comparison
+ return str(value)
+
+ # Sort items
+ try:
+ sorted_items = sorted(items, key=get_sort_value, reverse=(order == 'desc'))
+ return sorted_items
+ except Exception as e:
+ self.logger.warning(f"Error sorting items: {e}")
+ return items
+
+ def format_message(self, item: Dict[str, Any], feed: Dict[str, Any]) -> str:
+ """Format a feed item as a message for the mesh using configurable format with placeholders
+
+ Supported placeholders:
+ - {title} - item title
+ - {body} - item description/body
+ - {date} - relative time (e.g., "5m ago")
+ - {link} - item link URL
+ - {emoji} - emoji based on feed type
+ - {raw.field} - access any field from raw API response (e.g., {raw.Priority}, {raw.StartRoadwayLocation.RoadName})
+
+ Supported shortening functions:
+ - {field|truncate:N} - truncate to N characters
+ - {field|word_wrap:N} - wrap at N characters
+ - {field|first_words:N} - take first N words
+ - {field|regex:pattern} - extract using regex (first group or whole match)
+ - {field|regex:pattern:group} - extract specific capture group
+ - {field|if_regex:pattern:then:else} - if pattern matches, return "then", else "else"
+ - {field|switch:value1:result1:value2:result2:...:default} - exact match switch (e.g., switch:highest:đ´:high:đ :medium:đĄ:âĒ)
+ - {field|regex_cond:extract_pattern:check_pattern:then:group} - extract text, check if it matches check_pattern, return "then" if match else extracted text
+ """
+
+ # Get format string from feed config or use default
+ format_str = feed.get('output_format') or self.default_output_format
+
+ # Extract field values
+ title = item.get('title', 'Untitled')
+ body = item.get('description', '') or item.get('body', '')
+ # Clean HTML from body if present
+ if body:
+ import html
+ body = html.unescape(body)
+ # Convert line break tags to newlines before stripping other HTML
+ # Handle , , , , etc.
+ body = re.sub(r' ', '\n', body, flags=re.IGNORECASE)
+ # Convert paragraph tags to newlines (with spacing)
+ body = re.sub(r'
', '\n\n', body, flags=re.IGNORECASE)
+ body = re.sub(r'
]*>', '', body, flags=re.IGNORECASE)
+ # Remove remaining HTML tags
+ body = re.sub(r'<[^>]+>', '', body)
+ # Clean up whitespace (preserve intentional line breaks)
+ # Replace multiple newlines with double newline, then normalize spaces within lines
+ body = re.sub(r'\n\s*\n\s*\n+', '\n\n', body) # Multiple newlines -> double newline
+ lines = body.split('\n')
+ body = '\n'.join(' '.join(line.split()) for line in lines) # Normalize spaces per line
+ body = body.strip()
+
+ link = item.get('link', '')
+ published = item.get('published')
+ date_str = self._format_timestamp(published)
+
+ # Choose emoji based on feed type or content
+ emoji = "đĸ"
+ feed_name = feed.get('feed_name', '').lower()
+ if 'emergency' in feed_name or 'alert' in feed_name:
+ emoji = "đ¨"
+ elif 'warning' in feed_name:
+ emoji = "â ī¸"
+ elif 'info' in feed_name or 'news' in feed_name:
+ emoji = "âšī¸"
+
+ # Build replacement dictionary
+ replacements = {
+ 'title': title,
+ 'body': body,
+ 'date': date_str,
+ 'link': link,
+ 'emoji': emoji
+ }
+
+ # Get raw API data if available
+ raw_data = item.get('raw', {})
+
+ # Process format string with placeholders and functions
+ # Pattern: {field|function} or {field} or {raw.field.path}
+ def replace_placeholder(match):
+ full_match = match.group(0)
+ content = match.group(1) # Content inside {}
+
+ if '|' in content:
+ field_name, function = content.split('|', 1)
+ field_name = field_name.strip()
+ function = function.strip()
+
+ # Check if it's a raw field access
+ if field_name.startswith('raw.'):
+ value = str(self._get_nested_value(raw_data, field_name[4:], ''))
+ else:
+ value = replacements.get(field_name, '')
+
+ return self._apply_shortening(value, function)
+ else:
+ field_name = content.strip()
+
+ # Check if it's a raw field access
+ if field_name.startswith('raw.'):
+ value = self._get_nested_value(raw_data, field_name[4:], '')
+ # Convert to string, handling None and complex types
+ if value is None:
+ return ''
+ elif isinstance(value, (dict, list)):
+ # For complex types, convert to JSON string
+ try:
+ return json.dumps(value)
+ except Exception:
+ return str(value)
+ else:
+ return str(value)
+ else:
+ return replacements.get(field_name, '')
+
+ # Replace all placeholders
+ message = re.sub(r'\{([^}]+)\}', replace_placeholder, format_str)
+
+ # Final truncation if message is too long
+ if len(message) > self.max_message_length:
+ # Try to preserve structure by truncating at newline if possible
+ lines = message.split('\n')
+ if len(lines) > 1:
+ # Truncate last line
+ total_length = sum(len(line) + 1 for line in lines[:-1]) # +1 for newline
+ remaining = self.max_message_length - total_length - 3 # -3 for "..."
+ if remaining > 20:
+ lines[-1] = lines[-1][:remaining] + "..."
+ message = '\n'.join(lines)
+ else:
+ # Just truncate everything
+ message = message[:self.max_message_length - 3] + "..."
+ else:
+ message = message[:self.max_message_length - 3] + "..."
+
+ return message
+
+ def _queue_feed_message(self, feed: Dict[str, Any], item: Dict[str, Any], message: str):
+ """Queue a feed message for later sending"""
+ try:
+ with sqlite3.connect(self.db_path) as conn:
+ cursor = conn.cursor()
+ cursor.execute('''
+ INSERT INTO feed_message_queue
+ (feed_id, channel_name, message, item_id, item_title, priority)
+ VALUES (?, ?, ?, ?, ?, 0)
+ ''', (
+ feed['id'],
+ feed['channel_name'],
+ message,
+ item.get('id', ''),
+ item.get('title', '')[:200] # Limit title length
+ ))
+ conn.commit()
+ self.logger.debug(f"Queued feed message for {feed['channel_name']}: {item.get('title', '')[:50]}")
+ except Exception as e:
+ self.logger.error(f"Error queuing feed message: {e}")
+ self._record_feed_error(feed['id'], 'queue', str(e))
+
+ def _should_send_item(self, feed: Dict[str, Any], item: Dict[str, Any]) -> bool:
+ """Check if an item should be sent based on filter configuration
+
+ Filter config format:
+ {
+ "conditions": [
+ {"field": "Priority", "operator": "in", "values": ["highest", "high"]},
+ {"field": "EventStatus", "operator": "equals", "value": "open"},
+ {"field": "EventCategory", "operator": "not_equals", "value": "Maintenance"},
+ {"field": "raw.Priority", "operator": "matches", "pattern": "^(highest|high)$"}
+ ],
+ "logic": "AND" # or "OR"
+ }
+
+ Supported operators:
+ - equals: exact match
+ - not_equals: not exact match
+ - in: value is in list
+ - not_in: value is not in list
+ - matches: regex match
+ - not_matches: regex doesn't match
+ - contains: substring match
+ - not_contains: substring doesn't match
+ """
+ filter_config_str = feed.get('filter_config')
+ if not filter_config_str:
+ # No filter configured, send all items
+ return True
+
+ try:
+ filter_config = json.loads(filter_config_str) if isinstance(filter_config_str, str) else filter_config_str
+ except (json.JSONDecodeError, TypeError):
+ self.logger.warning(f"Invalid filter_config for feed {feed['id']}, sending all items")
+ return True
+
+ conditions = filter_config.get('conditions', [])
+ if not conditions:
+ # Empty conditions, send all items
+ return True
+
+ logic = filter_config.get('logic', 'AND').upper()
+
+ # Get raw data for field access
+ raw_data = item.get('raw', {})
+
+ # Evaluate each condition
+ results = []
+ for condition in conditions:
+ field_path = condition.get('field')
+ operator = condition.get('operator', 'equals')
+
+ if not field_path:
+ # Invalid condition, skip it
+ continue
+
+ # Get field value using nested access
+ field_value = self._get_nested_value(raw_data, field_path, '')
+ if not field_value and field_path.startswith('raw.'):
+ # Try without 'raw.' prefix
+ field_value = self._get_nested_value(raw_data, field_path[4:], '')
+
+ # If still not found, try top-level item fields
+ if not field_value:
+ field_value = self._get_nested_value(item, field_path, '')
+
+ # Convert to string for comparison
+ field_value_str = str(field_value).lower() if field_value is not None else ''
+
+ # Evaluate condition
+ result = False
+ if operator == 'equals':
+ compare_value = str(condition.get('value', '')).lower()
+ result = field_value_str == compare_value
+ elif operator == 'not_equals':
+ compare_value = str(condition.get('value', '')).lower()
+ result = field_value_str != compare_value
+ elif operator == 'in':
+ values = [str(v).lower() for v in condition.get('values', [])]
+ result = field_value_str in values
+ elif operator == 'not_in':
+ values = [str(v).lower() for v in condition.get('values', [])]
+ result = field_value_str not in values
+ elif operator == 'matches':
+ pattern = condition.get('pattern', '')
+ if pattern:
+ try:
+ result = bool(re.search(pattern, str(field_value), re.IGNORECASE))
+ except re.error:
+ result = False
+ elif operator == 'not_matches':
+ pattern = condition.get('pattern', '')
+ if pattern:
+ try:
+ result = not bool(re.search(pattern, str(field_value), re.IGNORECASE))
+ except re.error:
+ result = True
+ elif operator == 'contains':
+ compare_value = str(condition.get('value', '')).lower()
+ result = compare_value in field_value_str
+ elif operator == 'not_contains':
+ compare_value = str(condition.get('value', '')).lower()
+ result = compare_value not in field_value_str
+ else:
+ self.logger.warning(f"Unknown filter operator: {operator}")
+ result = True # Default to allowing if operator is unknown
+
+ results.append(result)
+
+ # Apply logic (AND or OR)
+ if logic == 'OR':
+ return any(results)
+ else: # AND (default)
+ return all(results)
+
+ async def _send_feed_item(self, feed: Dict[str, Any], item: Dict[str, Any]):
+ """Queue a feed item message instead of sending immediately"""
+ try:
+ message = self.format_message(item, feed)
+ # Queue the message instead of sending immediately
+ self._queue_feed_message(feed, item, message)
+ except Exception as e:
+ self.logger.error(f"Error processing feed item: {e}")
+ self._record_feed_error(feed['id'], 'other', str(e))
+
+ async def _wait_for_rate_limit(self, domain: str):
+ """Wait if needed to respect rate limits"""
+ if domain in self._domain_last_request:
+ last_request = self._domain_last_request[domain]
+ elapsed = time.time() - last_request
+ if elapsed < self.rate_limit_seconds:
+ wait_time = self.rate_limit_seconds - elapsed
+ await asyncio.sleep(wait_time)
+
+ self._domain_last_request[domain] = time.time()
+
+ def _get_enabled_feeds(self) -> List[Dict[str, Any]]:
+ """Get all enabled feed subscriptions from database"""
+ try:
+ with sqlite3.connect(self.db_path) as conn:
+ conn.row_factory = sqlite3.Row
+ cursor = conn.cursor()
+ cursor.execute('''
+ SELECT * FROM feed_subscriptions
+ WHERE enabled = 1
+ ORDER BY last_check_time ASC NULLS FIRST
+ ''')
+ rows = cursor.fetchall()
+ return [dict(row) for row in rows]
+ except Exception as e:
+ self.logger.error(f"Error getting enabled feeds: {e}")
+ return []
+
+ def _update_feed_last_check(self, feed_id: int):
+ """Update the last check time for a feed"""
+ try:
+ from datetime import datetime, timezone
+ # Use Python's datetime to ensure proper timezone handling
+ # Store in ISO format with timezone for JavaScript compatibility
+ now = datetime.now(timezone.utc)
+ now_str = now.isoformat() # ISO format: 2025-12-05T12:34:56.789+00:00
+
+ with sqlite3.connect(self.db_path) as conn:
+ cursor = conn.cursor()
+ cursor.execute('''
+ UPDATE feed_subscriptions
+ SET last_check_time = ?,
+ updated_at = ?
+ WHERE id = ?
+ ''', (now_str, now_str, feed_id))
+ conn.commit()
+ self.logger.debug(f"Updated last_check_time for feed {feed_id} to {now_str}")
+ except Exception as e:
+ self.logger.error(f"Error updating feed last check: {e}")
+
+ def _update_feed_last_item_id(self, feed_id: int, item_id: str):
+ """Update the last processed item ID for a feed"""
+ try:
+ with sqlite3.connect(self.db_path) as conn:
+ cursor = conn.cursor()
+ cursor.execute('''
+ UPDATE feed_subscriptions
+ SET last_item_id = ?,
+ updated_at = CURRENT_TIMESTAMP
+ WHERE id = ?
+ ''', (item_id, feed_id))
+ conn.commit()
+ except Exception as e:
+ self.logger.error(f"Error updating feed last item ID: {e}")
+
+ def _record_feed_activity(self, feed_id: int, item_id: str, item_title: str):
+ """Record that a feed item was processed"""
+ try:
+ with sqlite3.connect(self.db_path) as conn:
+ cursor = conn.cursor()
+ cursor.execute('''
+ INSERT INTO feed_activity (feed_id, item_id, item_title, message_sent)
+ VALUES (?, ?, ?, 1)
+ ''', (feed_id, item_id, item_title[:200])) # Limit title length
+ conn.commit()
+ except Exception as e:
+ self.logger.error(f"Error recording feed activity: {e}")
+
+ def _record_feed_error(self, feed_id: int, error_type: str, error_message: str):
+ """Record a feed error"""
+ try:
+ with sqlite3.connect(self.db_path) as conn:
+ cursor = conn.cursor()
+ cursor.execute('''
+ INSERT INTO feed_errors (feed_id, error_type, error_message)
+ VALUES (?, ?, ?)
+ ''', (feed_id, error_type, error_message[:500])) # Limit message length
+ conn.commit()
+ except Exception as e:
+ self.logger.error(f"Error recording feed error: {e}")
+
+ async def process_message_queue(self):
+ """Process queued feed messages and send them at configured intervals"""
+ try:
+ # Get all unsent messages, ordered by priority and queue time
+ with sqlite3.connect(self.db_path) as conn:
+ conn.row_factory = sqlite3.Row
+ cursor = conn.cursor()
+ cursor.execute('''
+ SELECT q.id, q.feed_id, q.channel_name, q.message, q.item_id, q.item_title,
+ f.message_send_interval_seconds
+ FROM feed_message_queue q
+ JOIN feed_subscriptions f ON q.feed_id = f.id
+ WHERE q.sent_at IS NULL
+ ORDER BY q.priority DESC, q.queued_at ASC
+ LIMIT 100
+ ''')
+ messages = cursor.fetchall()
+
+ if not messages:
+ return
+
+ # Group messages by feed to respect per-feed send intervals
+ feed_last_send: Dict[int, float] = {}
+
+ for msg in messages:
+ feed_id = msg['feed_id']
+ channel_name = msg['channel_name']
+ message_text = msg['message']
+ queue_id = msg['id']
+ item_id = msg['item_id']
+ item_title = msg['item_title']
+
+ # Get send interval for this feed (default if not set)
+ send_interval = msg['message_send_interval_seconds'] or self.default_send_interval
+
+ # Check if we need to wait before sending this feed's message
+ if feed_id in feed_last_send:
+ elapsed = time.time() - feed_last_send[feed_id]
+ if elapsed < send_interval:
+ wait_time = send_interval - elapsed
+ await asyncio.sleep(wait_time)
+
+ # Send the message
+ try:
+ success = await self.bot.command_manager.send_channel_message(channel_name, message_text)
+
+ if success:
+ # Mark as sent
+ with sqlite3.connect(self.db_path) as conn:
+ cursor = conn.cursor()
+ cursor.execute('''
+ UPDATE feed_message_queue
+ SET sent_at = CURRENT_TIMESTAMP
+ WHERE id = ?
+ ''', (queue_id,))
+ conn.commit()
+
+ # Record activity
+ self._record_feed_activity(feed_id, item_id, item_title)
+ self.logger.debug(f"Sent queued feed message to {channel_name}: {item_title[:50]}")
+ feed_last_send[feed_id] = time.time()
+ else:
+ self.logger.warning(f"Failed to send queued feed message to channel {channel_name}")
+ self._record_feed_error(feed_id, 'channel', f"Failed to send to channel {channel_name}")
+ # Don't mark as sent, will retry later
+
+ except Exception as e:
+ self.logger.error(f"Error sending queued feed message: {e}")
+ self._record_feed_error(feed_id, 'other', str(e))
+ # Don't mark as sent, will retry later
+
+ except Exception as e:
+ self.logger.error(f"Error processing message queue: {e}")
+
diff --git a/modules/message_handler.py b/modules/message_handler.py
index 2aed5ab..c6c302c 100644
--- a/modules/message_handler.py
+++ b/modules/message_handler.py
@@ -1562,9 +1562,29 @@ class MessageHandler:
# This allows greeter to work on its own configured channels even if not in monitor_channels
if 'greeter' in self.bot.command_manager.commands:
greeter_command = self.bot.command_manager.commands['greeter']
+ # First, check if this message should cancel a pending greeting (human greeting detection)
+ if greeter_command:
+ greeter_command.check_message_for_human_greeting(message)
+ # Then check if we should greet this user
if greeter_command and greeter_command.should_execute(message):
try:
- await greeter_command.execute(message)
+ success = await greeter_command.execute(message)
+
+ # Small delay to ensure send_response has completed
+ await asyncio.sleep(0.1)
+
+ # Determine if a response was sent
+ response_sent = False
+ if hasattr(greeter_command, 'last_response') and greeter_command.last_response:
+ response_sent = True
+ elif hasattr(self.bot.command_manager, '_last_response') and self.bot.command_manager._last_response:
+ response_sent = True
+
+ # Record command execution in stats database
+ if 'stats' in self.bot.command_manager.commands:
+ stats_command = self.bot.command_manager.commands['stats']
+ if stats_command:
+ stats_command.record_command(message, 'greeter', response_sent)
except Exception as e:
self.logger.error(f"Error executing greeter command: {e}")
@@ -1593,15 +1613,6 @@ class MessageHandler:
else:
self.logger.info(f"Keyword '{keyword}' matched, responding")
- # Record command execution in stats database
- if 'stats' in self.bot.command_manager.commands:
- stats_command = self.bot.command_manager.commands['stats']
- if stats_command:
- stats_command.record_command(message, keyword, response is not None)
-
- # Note: Command data capture is handled in command_manager.py after execution
- # to avoid duplicate messages to web viewer
-
# Track if this is a help response
if keyword == 'help':
help_response_sent = True
@@ -1611,9 +1622,21 @@ class MessageHandler:
plugin_command_with_response_matched = True
# Skip commands that handle their own responses (response is None)
+ # These will be recorded when they execute via execute_commands
if response is None:
continue
+ # Record command execution in stats database for keyword-matched commands with responses
+ # Commands without responses (response is None) are recorded in execute_commands to avoid double-counting
+ if 'stats' in self.bot.command_manager.commands:
+ stats_command = self.bot.command_manager.commands['stats']
+ if stats_command:
+ # response is not None here, so we know a response will be sent
+ stats_command.record_command(message, keyword, True)
+
+ # Note: Command data capture is handled in command_manager.py after execution
+ # to avoid duplicate messages to web viewer
+
# Send response
if message.is_dm:
await self.bot.command_manager.send_dm(message.sender_id, response)
diff --git a/modules/scheduler.py b/modules/scheduler.py
index f066762..e452a87 100644
--- a/modules/scheduler.py
+++ b/modules/scheduler.py
@@ -9,6 +9,8 @@ import threading
import schedule
import datetime
import pytz
+import sqlite3
+import json
from typing import Dict, Tuple
@@ -121,6 +123,7 @@ class MessageScheduler:
"""Run the scheduler in a separate thread"""
self.logger.info("Scheduler thread started")
last_log_time = 0
+ last_feed_poll_time = 0
while self.bot.connected:
current_time = self.get_current_time()
@@ -138,6 +141,83 @@ class MessageScheduler:
# Check for interval-based advertising
self.check_interval_advertising()
+ # Poll feeds every minute (but feeds themselves control their check intervals)
+ if time.time() - last_feed_poll_time >= 60: # Every 60 seconds
+ if (hasattr(self.bot, 'feed_manager') and self.bot.feed_manager and
+ hasattr(self.bot.feed_manager, 'enabled') and self.bot.feed_manager.enabled and
+ hasattr(self.bot, 'connected') and self.bot.connected):
+ # Run feed polling in async context
+ import asyncio
+ try:
+ loop = asyncio.get_event_loop()
+ except RuntimeError:
+ loop = asyncio.new_event_loop()
+ asyncio.set_event_loop(loop)
+
+ # Schedule feed polling
+ try:
+ loop.run_until_complete(self.bot.feed_manager.poll_all_feeds())
+ self.logger.debug("Feed polling cycle completed")
+ except Exception as e:
+ self.logger.error(f"Error in feed polling cycle: {e}")
+ last_feed_poll_time = time.time()
+
+ # Periodically refresh channels from device (every hour) to prevent stale data
+ if not hasattr(self, 'last_channel_refresh_time'):
+ # Initialize to current time so it doesn't run immediately on startup
+ self.last_channel_refresh_time = time.time()
+
+ channel_refresh_interval = self.bot.config.getint('Bot', 'channel_refresh_interval_seconds', fallback=3600) # Default 1 hour
+ if time.time() - self.last_channel_refresh_time >= channel_refresh_interval:
+ if (hasattr(self.bot, 'channel_manager') and self.bot.channel_manager and
+ hasattr(self.bot, 'connected') and self.bot.connected):
+ # Refresh channels from device
+ import asyncio
+ try:
+ loop = asyncio.get_event_loop()
+ except RuntimeError:
+ loop = asyncio.new_event_loop()
+ asyncio.set_event_loop(loop)
+
+ # Fetch channels and update database
+ self.bot.logger.debug("Periodic channel refresh: fetching channels from device")
+ loop.run_until_complete(self.bot.channel_manager.fetch_all_channels(force_refresh=True))
+ self.last_channel_refresh_time = time.time()
+
+ # Process pending channel operations from web viewer (every 5 seconds)
+ if not hasattr(self, 'last_channel_ops_check_time'):
+ self.last_channel_ops_check_time = 0
+
+ if time.time() - self.last_channel_ops_check_time >= 5: # Every 5 seconds
+ if (hasattr(self.bot, 'channel_manager') and self.bot.channel_manager and
+ hasattr(self.bot, 'connected') and self.bot.connected):
+ import asyncio
+ try:
+ loop = asyncio.get_event_loop()
+ except RuntimeError:
+ loop = asyncio.new_event_loop()
+ asyncio.set_event_loop(loop)
+
+ loop.run_until_complete(self._process_channel_operations())
+ self.last_channel_ops_check_time = time.time()
+
+ # Process feed message queue (every 2 seconds)
+ if not hasattr(self, 'last_message_queue_check_time'):
+ self.last_message_queue_check_time = 0
+
+ if time.time() - self.last_message_queue_check_time >= 2: # Every 2 seconds
+ if (hasattr(self.bot, 'feed_manager') and self.bot.feed_manager and
+ hasattr(self.bot, 'connected') and self.bot.connected):
+ import asyncio
+ try:
+ loop = asyncio.get_event_loop()
+ except RuntimeError:
+ loop = asyncio.new_event_loop()
+ asyncio.set_event_loop(loop)
+
+ loop.run_until_complete(self.bot.feed_manager.process_message_queue())
+ self.last_message_queue_check_time = time.time()
+
schedule.run_pending()
time.sleep(1)
@@ -194,3 +274,108 @@ class MessageScheduler:
self.logger.info("Interval-based flood advert sent successfully")
except Exception as e:
self.logger.error(f"Error sending interval-based advert: {e}")
+
+ async def _process_channel_operations(self):
+ """Process pending channel operations from the web viewer"""
+ try:
+ db_path = self.bot.db_manager.db_path
+
+ # Get pending operations
+ with sqlite3.connect(db_path) as conn:
+ conn.row_factory = sqlite3.Row
+ cursor = conn.cursor()
+
+ cursor.execute('''
+ SELECT id, operation_type, channel_idx, channel_name, channel_key_hex
+ FROM channel_operations
+ WHERE status = 'pending'
+ ORDER BY created_at ASC
+ LIMIT 10
+ ''')
+
+ operations = cursor.fetchall()
+
+ if not operations:
+ return
+
+ self.logger.info(f"Processing {len(operations)} pending channel operation(s)")
+
+ for op in operations:
+ op_id = op['id']
+ op_type = op['operation_type']
+ channel_idx = op['channel_idx']
+ channel_name = op['channel_name']
+ channel_key_hex = op['channel_key_hex']
+
+ try:
+ success = False
+ error_msg = None
+
+ if op_type == 'add':
+ # Add channel
+ if channel_key_hex:
+ # Custom channel with key
+ channel_secret = bytes.fromhex(channel_key_hex)
+ success = await self.bot.channel_manager.add_channel(
+ channel_idx, channel_name, channel_secret=channel_secret
+ )
+ else:
+ # Hashtag channel (firmware generates key)
+ success = await self.bot.channel_manager.add_channel(
+ channel_idx, channel_name
+ )
+
+ if success:
+ self.logger.info(f"Successfully processed channel add operation: {channel_name} at index {channel_idx}")
+ else:
+ error_msg = "Failed to add channel"
+
+ elif op_type == 'remove':
+ # Remove channel
+ success = await self.bot.channel_manager.remove_channel(channel_idx)
+
+ if success:
+ self.logger.info(f"Successfully processed channel remove operation: index {channel_idx}")
+ else:
+ error_msg = "Failed to remove channel"
+
+ # Update operation status
+ with sqlite3.connect(db_path) as conn:
+ cursor = conn.cursor()
+ if success:
+ cursor.execute('''
+ UPDATE channel_operations
+ SET status = 'completed',
+ processed_at = CURRENT_TIMESTAMP,
+ result_data = ?
+ WHERE id = ?
+ ''', (json.dumps({'success': True}), op_id))
+ else:
+ cursor.execute('''
+ UPDATE channel_operations
+ SET status = 'failed',
+ processed_at = CURRENT_TIMESTAMP,
+ error_message = ?
+ WHERE id = ?
+ ''', (error_msg or 'Unknown error', op_id))
+ conn.commit()
+
+ except Exception as e:
+ self.logger.error(f"Error processing channel operation {op_id}: {e}")
+ # Mark as failed
+ try:
+ with sqlite3.connect(db_path) as conn:
+ cursor = conn.cursor()
+ cursor.execute('''
+ UPDATE channel_operations
+ SET status = 'failed',
+ processed_at = CURRENT_TIMESTAMP,
+ error_message = ?
+ WHERE id = ?
+ ''', (str(e), op_id))
+ conn.commit()
+ except Exception as update_error:
+ self.logger.error(f"Error updating operation status: {update_error}")
+
+ except Exception as e:
+ self.logger.error(f"Error in _process_channel_operations: {e}")
diff --git a/modules/web_viewer/app.py b/modules/web_viewer/app.py
index e96d4aa..8b3e4c0 100644
--- a/modules/web_viewer/app.py
+++ b/modules/web_viewer/app.py
@@ -63,6 +63,9 @@ class BotDataViewer:
# Load configuration
self.config = self._load_config(config_path)
+ # Setup template context processor for global template variables
+ self._setup_template_context()
+
# Initialize databases
self._init_databases()
@@ -116,6 +119,25 @@ class BotDataViewer:
config.read(config_path)
return config
+ def _setup_template_context(self):
+ """Setup template context processor to inject global variables"""
+ @self.app.context_processor
+ def inject_template_vars():
+ """Inject variables available to all templates"""
+ # Check if greeter is enabled, defaulting to False if section doesn't exist
+ try:
+ greeter_enabled = self.config.getboolean('Greeter_Command', 'enabled', fallback=False)
+ except (configparser.NoSectionError, configparser.NoOptionError):
+ greeter_enabled = False
+
+ # Check if feed manager is enabled, defaulting to False if section doesn't exist
+ try:
+ feed_manager_enabled = self.config.getboolean('Feed_Manager', 'feed_manager_enabled', fallback=False)
+ except (configparser.NoSectionError, configparser.NoOptionError):
+ feed_manager_enabled = False
+
+ return dict(greeter_enabled=greeter_enabled, feed_manager_enabled=feed_manager_enabled)
+
def _init_databases(self):
"""Initialize database connections"""
try:
@@ -151,12 +173,13 @@ class BotDataViewer:
def _init_packet_stream_table(self):
"""Initialize the packet_stream table in bot_data.db"""
+ conn = None
try:
# Get database path from config
db_path = self.config.get('Database', 'path', fallback='bot_data.db')
# Connect to database and create table if it doesn't exist
- conn = sqlite3.connect(db_path)
+ conn = sqlite3.connect(db_path, timeout=30)
cursor = conn.cursor()
# Create packet_stream table with schema matching the INSERT statements
@@ -182,13 +205,18 @@ class BotDataViewer:
''')
conn.commit()
- conn.close()
self.logger.info(f"Initialized packet_stream table in {db_path}")
except Exception as e:
self.logger.error(f"Failed to initialize packet_stream table: {e}")
# Don't raise - allow web viewer to continue even if table init fails
+ finally:
+ if conn:
+ try:
+ conn.close()
+ except Exception as e:
+ self.logger.debug(f"Error closing init connection: {e}")
def _get_db_connection(self):
"""Get database connection - create new connection for each request to avoid threading issues"""
@@ -234,6 +262,16 @@ class BotDataViewer:
"""Greeter management page"""
return render_template('greeter.html')
+ @self.app.route('/feeds')
+ def feeds():
+ """Feed management page"""
+ return render_template('feeds.html')
+
+ @self.app.route('/radio')
+ def radio():
+ """Radio settings page"""
+ return render_template('radio.html')
+
# API Routes
@self.app.route('/api/health')
@@ -338,6 +376,7 @@ class BotDataViewer:
@self.app.route('/api/recent_commands')
def api_recent_commands():
"""API endpoint to get recent commands from database"""
+ conn = None
try:
import sqlite3
import json
@@ -349,7 +388,7 @@ class BotDataViewer:
# Get database path
db_path = self.config.get('Database', 'path', fallback='bot_data.db')
- conn = sqlite3.connect(db_path)
+ conn = sqlite3.connect(db_path, timeout=30)
cursor = conn.cursor()
cursor.execute('''
@@ -360,7 +399,6 @@ class BotDataViewer:
''', (cutoff_time,))
rows = cursor.fetchall()
- conn.close()
# Parse and return commands
commands = []
@@ -376,6 +414,12 @@ class BotDataViewer:
except Exception as e:
self.logger.error(f"Error getting recent commands: {e}")
return jsonify({'error': str(e)}), 500
+ finally:
+ if conn:
+ try:
+ conn.close()
+ except Exception as e:
+ self.logger.debug(f"Error closing recent_commands connection: {e}")
@self.app.route('/api/geocode-contact', methods=['POST'])
def api_geocode_contact():
@@ -876,6 +920,358 @@ class BotDataViewer:
except Exception as e:
self.logger.error(f"Error ungreeting user: {e}", exc_info=True)
return jsonify({'success': False, 'error': str(e)}), 500
+
+ # Feed management API endpoints
+ @self.app.route('/api/feeds')
+ def api_feeds():
+ """Get all feed subscriptions with statistics"""
+ try:
+ feeds = self._get_feed_subscriptions()
+ return jsonify(feeds)
+ except Exception as e:
+ self.logger.error(f"Error getting feeds: {e}")
+ return jsonify({'error': str(e)}), 500
+
+ @self.app.route('/api/feeds/')
+ def api_feed_detail(feed_id):
+ """Get detailed information about a specific feed"""
+ try:
+ feed = self._get_feed_subscription(feed_id)
+ if not feed:
+ return jsonify({'error': 'Feed not found'}), 404
+
+ # Get activity and errors
+ activity = self._get_feed_activity(feed_id)
+ errors = self._get_feed_errors(feed_id)
+
+ feed['activity'] = activity
+ feed['errors'] = errors
+
+ return jsonify(feed)
+ except Exception as e:
+ self.logger.error(f"Error getting feed detail: {e}")
+ return jsonify({'error': str(e)}), 500
+
+ @self.app.route('/api/feeds', methods=['POST'])
+ def api_create_feed():
+ """Create a new feed subscription"""
+ try:
+ data = request.get_json()
+ if not data:
+ return jsonify({'error': 'No data provided'}), 400
+
+ feed_id = self._create_feed_subscription(data)
+ return jsonify({'success': True, 'id': feed_id})
+ except Exception as e:
+ self.logger.error(f"Error creating feed: {e}")
+ return jsonify({'error': str(e)}), 500
+
+ @self.app.route('/api/feeds/', methods=['PUT'])
+ def api_update_feed(feed_id):
+ """Update an existing feed subscription"""
+ try:
+ data = request.get_json()
+ if not data:
+ return jsonify({'error': 'No data provided'}), 400
+
+ success = self._update_feed_subscription(feed_id, data)
+ if not success:
+ return jsonify({'error': 'Feed not found'}), 404
+
+ return jsonify({'success': True})
+ except Exception as e:
+ self.logger.error(f"Error updating feed: {e}")
+ return jsonify({'error': str(e)}), 500
+
+ @self.app.route('/api/feeds/', methods=['DELETE'])
+ def api_delete_feed(feed_id):
+ """Delete a feed subscription"""
+ try:
+ success = self._delete_feed_subscription(feed_id)
+ if not success:
+ return jsonify({'error': 'Feed not found'}), 404
+
+ return jsonify({'success': True})
+ except Exception as e:
+ self.logger.error(f"Error deleting feed: {e}")
+ return jsonify({'error': str(e)}), 500
+
+ @self.app.route('/api/feeds/default-format', methods=['GET'])
+ def api_get_default_format():
+ """Get the default output format from config"""
+ try:
+ default_format = self.config.get('Feed_Manager', 'default_output_format',
+ fallback='{emoji} {body|truncate:100} - {date}\n{link|truncate:50}')
+ return jsonify({'default_format': default_format})
+ except Exception as e:
+ self.logger.error(f"Error getting default format: {e}")
+ return jsonify({'default_format': '{emoji} {body|truncate:100} - {date}\n{link|truncate:50}'})
+
+ @self.app.route('/api/feeds/preview', methods=['POST'])
+ def api_preview_feed():
+ """Preview feed items with custom output format"""
+ try:
+ data = request.get_json()
+ if not data or 'feed_url' not in data:
+ return jsonify({'error': 'feed_url is required'}), 400
+
+ feed_url = data['feed_url']
+ feed_type = data.get('feed_type', 'rss')
+ output_format = data.get('output_format', '')
+ api_config = data.get('api_config', {})
+ filter_config = data.get('filter_config')
+ sort_config = data.get('sort_config')
+
+ # Get default format from config if not provided
+ if not output_format:
+ output_format = self.config.get('Feed_Manager', 'default_output_format',
+ fallback='{emoji} {body|truncate:100} - {date}\n{link|truncate:50}')
+
+ # Fetch and format feed items
+ preview_items = self._preview_feed_items(feed_url, feed_type, output_format, api_config, filter_config, sort_config)
+
+ return jsonify({
+ 'success': True,
+ 'items': preview_items
+ })
+ except Exception as e:
+ self.logger.error(f"Error previewing feed: {e}")
+ return jsonify({'error': str(e)}), 500
+
+ @self.app.route('/api/feeds/test', methods=['POST'])
+ def api_test_feed():
+ """Test a feed URL and return preview of recent items"""
+ try:
+ data = request.get_json()
+ if not data or 'url' not in data:
+ return jsonify({'error': 'URL is required'}), 400
+
+ # This would require feed_manager - for now just validate URL
+ from urllib.parse import urlparse
+ url = data['url']
+ result = urlparse(url)
+ if not all([result.scheme in ['http', 'https'], result.netloc]):
+ return jsonify({'error': 'Invalid URL format'}), 400
+
+ return jsonify({'success': True, 'message': 'URL validated (full test requires feed manager)'})
+ except Exception as e:
+ self.logger.error(f"Error testing feed: {e}")
+ return jsonify({'error': str(e)}), 500
+
+ @self.app.route('/api/feeds/stats')
+ def api_feed_stats():
+ """Get aggregate feed statistics"""
+ try:
+ stats = self._get_feed_statistics()
+ return jsonify(stats)
+ except Exception as e:
+ self.logger.error(f"Error getting feed stats: {e}")
+ return jsonify({'error': str(e)}), 500
+
+ @self.app.route('/api/feeds//activity')
+ def api_feed_activity(feed_id):
+ """Get activity log for a specific feed"""
+ try:
+ activity = self._get_feed_activity(feed_id, limit=50)
+ return jsonify({'activity': activity})
+ except Exception as e:
+ self.logger.error(f"Error getting feed activity: {e}")
+ return jsonify({'error': str(e)}), 500
+
+ @self.app.route('/api/feeds//errors')
+ def api_feed_errors(feed_id):
+ """Get error history for a specific feed"""
+ try:
+ errors = self._get_feed_errors(feed_id, limit=20)
+ return jsonify({'errors': errors})
+ except Exception as e:
+ self.logger.error(f"Error getting feed errors: {e}")
+ return jsonify({'error': str(e)}), 500
+
+ @self.app.route('/api/feeds//refresh', methods=['POST'])
+ def api_refresh_feed(feed_id):
+ """Manually trigger a feed check"""
+ try:
+ # This would trigger feed_manager to poll this feed immediately
+ # For now, just acknowledge the request
+ return jsonify({'success': True, 'message': 'Feed refresh queued'})
+ except Exception as e:
+ self.logger.error(f"Error refreshing feed: {e}")
+ return jsonify({'error': str(e)}), 500
+
+ # Channel management API endpoints
+ @self.app.route('/api/channels')
+ def api_channels():
+ """Get all configured channels"""
+ try:
+ channels = self._get_channels()
+ return jsonify({'channels': channels})
+ except Exception as e:
+ self.logger.error(f"Error getting channels: {e}")
+ return jsonify({'error': str(e)}), 500
+
+ @self.app.route('/api/channels', methods=['POST'])
+ def api_create_channel():
+ """Create a new channel (hashtag or custom)"""
+ try:
+ data = request.get_json()
+ if not data or 'name' not in data:
+ return jsonify({'error': 'Channel name is required'}), 400
+
+ channel_name = data.get('name', '').strip()
+ channel_idx = data.get('channel_idx')
+ channel_key = data.get('channel_key', '').strip()
+
+ if not channel_name:
+ return jsonify({'error': 'Channel name cannot be empty'}), 400
+
+ # If channel_idx not provided, find the lowest available index
+ if channel_idx is None:
+ channel_idx = self._get_lowest_available_channel_index()
+ if channel_idx is None:
+ return jsonify({'error': 'No available channel slots. All 40 channels are in use.'}), 400
+
+ # Determine if it's a hashtag channel
+ is_hashtag = channel_name.startswith('#')
+
+ # Validate custom channel has key
+ if not is_hashtag and not channel_key:
+ return jsonify({'error': 'Channel key is required for custom channels (channels without # prefix)'}), 400
+
+ # Validate key format if provided
+ if channel_key:
+ if len(channel_key) != 32:
+ return jsonify({'error': 'Channel key must be exactly 32 hexadecimal characters'}), 400
+ if not all(c in '0123456789abcdefABCDEF' for c in channel_key):
+ return jsonify({'error': 'Channel key must contain only hexadecimal characters (0-9, a-f, A-F)'}), 400
+
+ # Try to create channel via bot's channel manager
+ result = self._add_channel_for_web(channel_idx, channel_name, channel_key if not is_hashtag else None)
+
+ if result.get('success'):
+ if result.get('pending'):
+ # Operation is queued, return operation_id for polling
+ return jsonify({
+ 'success': True,
+ 'pending': True,
+ 'operation_id': result.get('operation_id'),
+ 'message': result.get('message', 'Channel operation queued')
+ })
+ else:
+ return jsonify({'success': True, 'message': 'Channel created successfully'})
+ else:
+ return jsonify({'error': result.get('error', 'Failed to create channel')}), 500
+
+ except Exception as e:
+ self.logger.error(f"Error creating channel: {e}")
+ return jsonify({'error': str(e)}), 500
+
+ @self.app.route('/api/channels/', methods=['DELETE'])
+ def api_delete_channel(channel_idx):
+ """Remove a channel"""
+ try:
+ result = self._remove_channel_for_web(channel_idx)
+ if result.get('success'):
+ if result.get('pending'):
+ # Operation is queued, return operation_id for polling
+ return jsonify({
+ 'success': True,
+ 'pending': True,
+ 'operation_id': result.get('operation_id'),
+ 'message': result.get('message', 'Channel operation queued')
+ })
+ else:
+ return jsonify({'success': True, 'message': 'Channel deleted successfully'})
+ else:
+ return jsonify({'error': result.get('error', 'Failed to delete channel')}), 500
+ except Exception as e:
+ self.logger.error(f"Error deleting channel: {e}")
+ return jsonify({'error': str(e)}), 500
+
+ @self.app.route('/api/channel-operations/', methods=['GET'])
+ def api_get_operation_status(operation_id):
+ """Get status of a channel operation"""
+ try:
+ conn = self._get_db_connection()
+ cursor = conn.cursor()
+ cursor.execute('''
+ SELECT status, error_message, result_data, processed_at
+ FROM channel_operations
+ WHERE id = ?
+ ''', (operation_id,))
+
+ result = cursor.fetchone()
+ conn.close()
+
+ if not result:
+ return jsonify({'error': 'Operation not found'}), 404
+
+ status, error_msg, result_data, processed_at = result
+
+ return jsonify({
+ 'operation_id': operation_id,
+ 'status': status,
+ 'error_message': error_msg,
+ 'processed_at': processed_at,
+ 'result_data': json.loads(result_data) if result_data else None
+ })
+ except Exception as e:
+ self.logger.error(f"Error getting operation status: {e}")
+ return jsonify({'error': str(e)}), 500
+
+ @self.app.route('/api/channels/validate', methods=['POST'])
+ def api_validate_channel():
+ """Validate if a channel exists or can be created"""
+ try:
+ data = request.get_json()
+ if not data or 'name' not in data:
+ return jsonify({'error': 'Channel name is required'}), 400
+
+ channel_name = data['name']
+ # Check if channel exists
+ channel_num = self._get_channel_number(channel_name)
+
+ return jsonify({
+ 'exists': channel_num is not None,
+ 'channel_num': channel_num
+ })
+ except Exception as e:
+ self.logger.error(f"Error validating channel: {e}")
+ return jsonify({'error': str(e)}), 500
+
+ @self.app.route('/api/channels/', methods=['PUT'])
+ def api_update_channel(channel_idx):
+ """Update channel name or configuration"""
+ try:
+ data = request.get_json()
+ if not data:
+ return jsonify({'error': 'No data provided'}), 400
+
+ # This would use channel_manager
+ return jsonify({'success': True, 'message': 'Channel update requires bot connection'})
+ except Exception as e:
+ self.logger.error(f"Error updating channel: {e}")
+ return jsonify({'error': str(e)}), 500
+
+ @self.app.route('/api/channels/stats')
+ def api_channel_stats():
+ """Get channel statistics and usage data"""
+ try:
+ stats = self._get_channel_statistics()
+ return jsonify(stats)
+ except Exception as e:
+ self.logger.error(f"Error getting channel stats: {e}")
+ return jsonify({'error': str(e)}), 500
+
+ @self.app.route('/api/channels//feeds')
+ def api_channel_feeds(channel_idx):
+ """Get all feed subscriptions for a specific channel"""
+ try:
+ feeds = self._get_feeds_by_channel(channel_idx)
+ return jsonify({'feeds': feeds})
+ except Exception as e:
+ self.logger.error(f"Error getting channel feeds: {e}")
+ return jsonify({'error': str(e)}), 500
def _setup_socketio_handlers(self):
"""Setup SocketIO event handlers using modern patterns"""
@@ -979,7 +1375,11 @@ class BotDataViewer:
def poll_database():
last_timestamp = 0
+ consecutive_errors = 0
+ max_consecutive_errors = 10
+
while True:
+ conn = None
try:
import time
import sqlite3
@@ -988,8 +1388,8 @@ class BotDataViewer:
# Get database path
db_path = self.config.get('Database', 'path', fallback='bot_data.db')
- # Connect to database
- conn = sqlite3.connect(db_path)
+ # Connect to database with timeout to prevent hanging
+ conn = sqlite3.connect(db_path, timeout=30)
cursor = conn.cursor()
# Get new data since last poll
@@ -1000,7 +1400,6 @@ class BotDataViewer:
''', (last_timestamp,))
rows = cursor.fetchall()
- conn.close()
# Process new data
for timestamp, data_json, data_type in rows:
@@ -1016,18 +1415,50 @@ class BotDataViewer:
self._handle_packet_data(data) # Treat routing as packet data
except Exception as e:
- self.logger.debug(f"Error processing database data: {e}")
+ self.logger.warning(f"Error processing database data: {e}")
# Update last timestamp
if rows:
last_timestamp = rows[-1][0]
+ # Reset error counter on success
+ consecutive_errors = 0
+
# Sleep before next poll
time.sleep(0.5) # Poll every 500ms
+ except sqlite3.OperationalError as e:
+ consecutive_errors += 1
+ error_msg = str(e)
+
+ # Log at appropriate level based on error frequency
+ if consecutive_errors >= max_consecutive_errors:
+ self.logger.error(f"Database polling persistent error (attempt {consecutive_errors}): {error_msg}")
+ # Exponential backoff for persistent errors
+ time.sleep(min(60, 2 ** min(consecutive_errors - max_consecutive_errors, 5)))
+ elif consecutive_errors > 3:
+ self.logger.warning(f"Database polling error (attempt {consecutive_errors}): {error_msg}")
+ time.sleep(5) # Wait longer on repeated errors
+ else:
+ self.logger.debug(f"Database polling error (attempt {consecutive_errors}): {error_msg}")
+ time.sleep(1) # Wait longer on error
+
except Exception as e:
- self.logger.debug(f"Database polling error: {e}")
- time.sleep(1) # Wait longer on error
+ consecutive_errors += 1
+ if consecutive_errors >= max_consecutive_errors:
+ self.logger.error(f"Database polling unexpected error (attempt {consecutive_errors}): {e}", exc_info=True)
+ time.sleep(min(60, 2 ** min(consecutive_errors - max_consecutive_errors, 5)))
+ else:
+ self.logger.warning(f"Database polling unexpected error (attempt {consecutive_errors}): {e}")
+ time.sleep(2)
+
+ finally:
+ # Always close connection, even on error
+ if conn:
+ try:
+ conn.close()
+ except Exception as e:
+ self.logger.debug(f"Error closing database connection: {e}")
# Start polling thread
polling_thread = threading.Thread(target=poll_database, daemon=True)
@@ -1059,6 +1490,7 @@ class BotDataViewer:
def _cleanup_old_data(self, days_to_keep: int = 7):
"""Clean up old packet stream data to prevent database bloat"""
+ conn = None
try:
import sqlite3
import time
@@ -1068,7 +1500,8 @@ class BotDataViewer:
# Get database path
db_path = self.config.get('Database', 'path', fallback='bot_data.db')
- conn = sqlite3.connect(db_path)
+ # Use timeout to prevent hanging
+ conn = sqlite3.connect(db_path, timeout=30)
cursor = conn.cursor()
# Clean up old packet stream data
@@ -1076,13 +1509,18 @@ class BotDataViewer:
deleted_count = cursor.rowcount
conn.commit()
- conn.close()
if deleted_count > 0:
self.logger.info(f"Cleaned up {deleted_count} old packet stream entries (older than {days_to_keep} days)")
except Exception as e:
self.logger.error(f"Error cleaning up old packet stream data: {e}")
+ finally:
+ if conn:
+ try:
+ conn.close()
+ except Exception as e:
+ self.logger.debug(f"Error closing cleanup connection: {e}")
def _get_database_stats(self, top_users_window='all', top_commands_window='all',
top_paths_window='all', top_channels_window='all'):
@@ -1902,6 +2340,1148 @@ class BotDataViewer:
return {'error': str(e)}
+ def _get_feed_subscriptions(self, channel_filter=None):
+ """Get all feed subscriptions, optionally filtered by channel"""
+ import sqlite3
+ conn = None
+ try:
+ conn = self._get_db_connection()
+ conn.row_factory = sqlite3.Row
+ cursor = conn.cursor()
+
+ if channel_filter:
+ cursor.execute('''
+ SELECT * FROM feed_subscriptions
+ WHERE channel_name = ?
+ ORDER BY id
+ ''', (channel_filter,))
+ else:
+ cursor.execute('''
+ SELECT * FROM feed_subscriptions
+ ORDER BY id
+ ''')
+
+ rows = cursor.fetchall()
+ feeds = []
+ for row in rows:
+ feed = dict(row)
+ # Get feed count for this channel
+ cursor.execute('''
+ SELECT COUNT(*) FROM feed_activity
+ WHERE feed_id = ?
+ ''', (feed['id'],))
+ feed['item_count'] = cursor.fetchone()[0]
+
+ # Get error count
+ cursor.execute('''
+ SELECT COUNT(*) FROM feed_errors
+ WHERE feed_id = ? AND resolved_at IS NULL
+ ''', (feed['id'],))
+ feed['error_count'] = cursor.fetchone()[0]
+
+ feeds.append(feed)
+
+ return {'feeds': feeds, 'total': len(feeds)}
+ except Exception as e:
+ self.logger.error(f"Error getting feed subscriptions: {e}")
+ return {'feeds': [], 'total': 0, 'error': str(e)}
+ finally:
+ if conn:
+ conn.close()
+
+ def _get_feed_subscription(self, feed_id):
+ """Get a single feed subscription by ID"""
+ import sqlite3
+ conn = None
+ try:
+ conn = self._get_db_connection()
+ conn.row_factory = sqlite3.Row
+ cursor = conn.cursor()
+ cursor.execute('SELECT * FROM feed_subscriptions WHERE id = ?', (feed_id,))
+ row = cursor.fetchone()
+ return dict(row) if row else None
+ except Exception as e:
+ self.logger.error(f"Error getting feed subscription: {e}")
+ return None
+ finally:
+ if conn:
+ conn.close()
+
+ def _create_feed_subscription(self, data):
+ """Create a new feed subscription"""
+ import sqlite3
+ import json
+ conn = None
+ try:
+ feed_type = data.get('feed_type')
+ feed_url = data.get('feed_url')
+ channel_name = data.get('channel_name')
+ feed_name = data.get('feed_name')
+ check_interval = data.get('check_interval_seconds', 300)
+ api_config = data.get('api_config')
+ output_format = data.get('output_format')
+ message_send_interval = data.get('message_send_interval_seconds')
+
+ if not all([feed_type, feed_url, channel_name]):
+ raise ValueError("feed_type, feed_url, and channel_name are required")
+
+ conn = self._get_db_connection()
+ cursor = conn.cursor()
+
+ api_config_str = json.dumps(api_config) if api_config else None
+
+ cursor.execute('''
+ INSERT INTO feed_subscriptions
+ (feed_type, feed_url, channel_name, feed_name, check_interval_seconds, api_config, output_format, message_send_interval_seconds)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
+ ''', (feed_type, feed_url, channel_name, feed_name, check_interval, api_config_str, output_format, message_send_interval))
+
+ conn.commit()
+ return cursor.lastrowid
+ except Exception as e:
+ if conn:
+ conn.rollback()
+ raise
+ finally:
+ if conn:
+ conn.close()
+
+ def _update_feed_subscription(self, feed_id, data):
+ """Update a feed subscription"""
+ import sqlite3
+ import json
+ conn = None
+ try:
+ conn = self._get_db_connection()
+ cursor = conn.cursor()
+
+ updates = []
+ params = []
+
+ if 'feed_name' in data:
+ updates.append('feed_name = ?')
+ params.append(data['feed_name'])
+
+ if 'check_interval_seconds' in data:
+ updates.append('check_interval_seconds = ?')
+ params.append(data['check_interval_seconds'])
+
+ if 'enabled' in data:
+ updates.append('enabled = ?')
+ params.append(1 if data['enabled'] else 0)
+
+ if 'api_config' in data:
+ updates.append('api_config = ?')
+ params.append(json.dumps(data['api_config']) if data['api_config'] else None)
+
+ if 'output_format' in data:
+ updates.append('output_format = ?')
+ params.append(data['output_format'] if data['output_format'] else None)
+
+ if 'message_send_interval_seconds' in data:
+ updates.append('message_send_interval_seconds = ?')
+ params.append(float(data['message_send_interval_seconds']) if data['message_send_interval_seconds'] else None)
+
+ if 'filter_config' in data:
+ updates.append('filter_config = ?')
+ params.append(json.dumps(data['filter_config']) if data['filter_config'] else None)
+
+ if 'sort_config' in data:
+ updates.append('sort_config = ?')
+ params.append(json.dumps(data['sort_config']) if data['sort_config'] else None)
+
+ if 'message_send_interval_seconds' in data:
+ updates.append('message_send_interval_seconds = ?')
+ params.append(data['message_send_interval_seconds'])
+
+ if not updates:
+ return True # Nothing to update
+
+ updates.append('updated_at = CURRENT_TIMESTAMP')
+ params.append(feed_id)
+
+ query = f'UPDATE feed_subscriptions SET {", ".join(updates)} WHERE id = ?'
+ cursor.execute(query, params)
+ conn.commit()
+
+ return cursor.rowcount > 0
+ except Exception as e:
+ if conn:
+ conn.rollback()
+ raise
+ finally:
+ if conn:
+ conn.close()
+
+ def _delete_feed_subscription(self, feed_id):
+ """Delete a feed subscription"""
+ import sqlite3
+ conn = None
+ try:
+ conn = self._get_db_connection()
+ cursor = conn.cursor()
+ cursor.execute('DELETE FROM feed_subscriptions WHERE id = ?', (feed_id,))
+ conn.commit()
+ return cursor.rowcount > 0
+ except Exception as e:
+ if conn:
+ conn.rollback()
+ raise
+ finally:
+ if conn:
+ conn.close()
+
+ def _get_feed_activity(self, feed_id, limit=50):
+ """Get activity log for a feed"""
+ import sqlite3
+ conn = None
+ try:
+ conn = self._get_db_connection()
+ conn.row_factory = sqlite3.Row
+ cursor = conn.cursor()
+ cursor.execute('''
+ SELECT * FROM feed_activity
+ WHERE feed_id = ?
+ ORDER BY processed_at DESC
+ LIMIT ?
+ ''', (feed_id, limit))
+ rows = cursor.fetchall()
+ return [dict(row) for row in rows]
+ except Exception as e:
+ self.logger.error(f"Error getting feed activity: {e}")
+ return []
+ finally:
+ if conn:
+ conn.close()
+
+ def _get_feed_errors(self, feed_id, limit=20):
+ """Get error history for a feed"""
+ import sqlite3
+ conn = None
+ try:
+ conn = self._get_db_connection()
+ conn.row_factory = sqlite3.Row
+ cursor = conn.cursor()
+ cursor.execute('''
+ SELECT * FROM feed_errors
+ WHERE feed_id = ?
+ ORDER BY occurred_at DESC
+ LIMIT ?
+ ''', (feed_id, limit))
+ rows = cursor.fetchall()
+ return [dict(row) for row in rows]
+ except Exception as e:
+ self.logger.error(f"Error getting feed errors: {e}")
+ return []
+ finally:
+ if conn:
+ conn.close()
+
+ def _get_feed_statistics(self):
+ """Get aggregate feed statistics"""
+ import sqlite3
+ conn = None
+ try:
+ conn = self._get_db_connection()
+ cursor = conn.cursor()
+
+ stats = {}
+
+ # Total subscriptions
+ cursor.execute('SELECT COUNT(*) FROM feed_subscriptions')
+ stats['total_subscriptions'] = cursor.fetchone()[0]
+
+ # Enabled subscriptions
+ cursor.execute('SELECT COUNT(*) FROM feed_subscriptions WHERE enabled = 1')
+ stats['enabled_subscriptions'] = cursor.fetchone()[0]
+
+ # Items processed in last 24h
+ cursor.execute('''
+ SELECT COUNT(*) FROM feed_activity
+ WHERE processed_at > datetime('now', '-24 hours')
+ ''')
+ stats['items_24h'] = cursor.fetchone()[0]
+
+ # Items processed in last 7d
+ cursor.execute('''
+ SELECT COUNT(*) FROM feed_activity
+ WHERE processed_at > datetime('now', '-7 days')
+ ''')
+ stats['items_7d'] = cursor.fetchone()[0]
+
+ # Error count
+ cursor.execute('''
+ SELECT COUNT(*) FROM feed_errors
+ WHERE resolved_at IS NULL
+ ''')
+ stats['active_errors'] = cursor.fetchone()[0]
+
+ # Most active channels
+ cursor.execute('''
+ SELECT channel_name, COUNT(*) as feed_count
+ FROM feed_subscriptions
+ WHERE enabled = 1
+ GROUP BY channel_name
+ ORDER BY feed_count DESC
+ LIMIT 10
+ ''')
+ stats['top_channels'] = [{'channel': row[0], 'count': row[1]} for row in cursor.fetchall()]
+
+ return stats
+ except Exception as e:
+ self.logger.error(f"Error getting feed statistics: {e}")
+ return {'error': str(e)}
+ finally:
+ if conn:
+ conn.close()
+
+ def _get_feeds_by_channel(self, channel_idx):
+ """Get all feeds for a specific channel index"""
+ # First get channel name from index
+ # This would require channel_manager access
+ # For now, return empty list
+ return []
+
+ def _get_channels(self):
+ """Get all configured channels from database"""
+ import sqlite3
+ conn = None
+ try:
+ conn = self._get_db_connection()
+ conn.row_factory = sqlite3.Row
+ cursor = conn.cursor()
+
+ cursor.execute('''
+ SELECT channel_idx, channel_name, channel_type, channel_key_hex, last_updated
+ FROM channels
+ ORDER BY channel_idx
+ ''')
+
+ rows = cursor.fetchall()
+ channels = []
+ for row in rows:
+ channels.append({
+ 'channel_idx': row['channel_idx'],
+ 'index': row['channel_idx'], # Alias for compatibility
+ 'name': row['channel_name'],
+ 'channel_name': row['channel_name'], # Alias for compatibility
+ 'type': row['channel_type'] or 'hashtag',
+ 'key_hex': row['channel_key_hex'],
+ 'last_updated': row['last_updated']
+ })
+
+ return channels
+ except Exception as e:
+ self.logger.error(f"Error getting channels: {e}")
+ return []
+ finally:
+ if conn:
+ conn.close()
+
+ def _get_channel_number(self, channel_name):
+ """Get channel number from channel name"""
+ # This would use channel_manager
+ # For now, return None
+ return None
+
+ def _get_lowest_available_channel_index(self):
+ """Get the lowest available channel index (0-39)"""
+ try:
+ channels = self._get_channels()
+ used_indices = {c['channel_idx'] for c in channels}
+
+ # Find the lowest available index
+ for i in range(40):
+ if i not in used_indices:
+ return i
+
+ # All channels are used
+ return None
+ except Exception as e:
+ self.logger.error(f"Error getting lowest available channel index: {e}")
+ return None
+
+ def _get_channel_statistics(self):
+ """Get channel statistics"""
+ import sqlite3
+ conn = None
+ try:
+ conn = self._get_db_connection()
+ cursor = conn.cursor()
+
+ # Get feed count per channel
+ cursor.execute('''
+ SELECT channel_name, COUNT(*) as feed_count
+ FROM feed_subscriptions
+ WHERE enabled = 1
+ GROUP BY channel_name
+ ''')
+
+ channel_feeds = {row[0]: row[1] for row in cursor.fetchall()}
+
+ return {
+ 'channels_with_feeds': len(channel_feeds),
+ 'channel_feed_counts': channel_feeds
+ }
+ except Exception as e:
+ self.logger.error(f"Error getting channel statistics: {e}")
+ return {'error': str(e)}
+ finally:
+ if conn:
+ conn.close()
+
+ def _preview_feed_items(self, feed_url: str, feed_type: str, output_format: str, api_config: dict = None, filter_config: dict = None, sort_config: dict = None) -> List[Dict[str, Any]]:
+ """Preview feed items with custom output format (standalone, doesn't require bot)"""
+ import feedparser
+ import requests
+ import html
+ import re
+ from datetime import datetime, timezone
+
+ try:
+ items = []
+
+ if feed_type == 'rss':
+ # Fetch RSS feed
+ response = requests.get(feed_url, timeout=30, headers={'User-Agent': 'MeshCoreBot/1.0 FeedManager'})
+ response.raise_for_status()
+ parsed = feedparser.parse(response.text)
+
+ # Get items (we'll filter and limit later)
+ for entry in parsed.entries[:20]: # Fetch more items to account for filtering
+ # Parse published date
+ published = None
+ if hasattr(entry, 'published_parsed') and entry.published_parsed:
+ try:
+ published = datetime(*entry.published_parsed[:6], tzinfo=timezone.utc)
+ except Exception:
+ pass
+
+ items.append({
+ 'title': entry.get('title', 'Untitled'),
+ 'description': entry.get('description', ''),
+ 'link': entry.get('link', ''),
+ 'published': published
+ })
+
+ elif feed_type == 'api':
+ # Fetch API feed
+ method = api_config.get('method', 'GET').upper()
+ headers = api_config.get('headers', {})
+ params = api_config.get('params', {})
+ body = api_config.get('body')
+ parser_config = api_config.get('response_parser', {})
+
+ if method == 'POST':
+ response = requests.post(feed_url, headers=headers, params=params, json=body, timeout=30)
+ else:
+ response = requests.get(feed_url, headers=headers, params=params, timeout=30)
+ response.raise_for_status()
+
+ # Try to parse JSON, handle cases where response might be a string
+ try:
+ data = response.json()
+ except ValueError:
+ # If JSON parsing fails, try to get text and see if it's an error message
+ text = response.text
+ raise Exception(f"API returned non-JSON response: {text[:200]}")
+
+ # Check if response is an error message (string)
+ if isinstance(data, str):
+ raise Exception(f"API returned error message: {data[:200]}")
+
+ # Ensure data is a dict or list
+ if not isinstance(data, (dict, list)):
+ raise Exception(f"API response is not a valid JSON object or array: {type(data).__name__} - {str(data)[:200]}")
+
+ # Extract items using parser config
+ items_path = parser_config.get('items_path', '')
+ if items_path:
+ parts = items_path.split('.')
+ items_data = data
+ for part in parts:
+ if isinstance(items_data, dict):
+ items_data = items_data.get(part, [])
+ else:
+ raise Exception(f"Cannot navigate path '{items_path}': expected dict at '{part}', got {type(items_data).__name__}")
+ else:
+ # If no items_path, data should be a list or we wrap it
+ if isinstance(data, list):
+ items_data = data
+ elif isinstance(data, dict):
+ # If it's a dict, try to find common array fields
+ items_data = data.get('items', data.get('data', data.get('results', [data])))
+ else:
+ items_data = [data]
+
+ # Ensure items_data is a list
+ if not isinstance(items_data, list):
+ items_data = [items_data]
+
+ # Get items (we'll filter and limit later)
+ id_field = parser_config.get('id_field', 'id')
+ title_field = parser_config.get('title_field', 'title')
+ description_field = parser_config.get('description_field', 'description')
+ timestamp_field = parser_config.get('timestamp_field', 'created_at')
+
+ # Helper function to get nested values
+ def get_nested_value(data, path, default=''):
+ if not path or not data:
+ return default
+ parts = path.split('.')
+ value = data
+ for part in parts:
+ if isinstance(value, dict):
+ value = value.get(part)
+ elif isinstance(value, list):
+ try:
+ idx = int(part)
+ if 0 <= idx < len(value):
+ value = value[idx]
+ else:
+ return default
+ except (ValueError, TypeError):
+ return default
+ else:
+ return default
+ if value is None:
+ return default
+ return value if value is not None else default
+
+ for item_data in items_data[:20]: # Fetch more items to account for filtering
+ # Ensure item_data is a dict
+ if not isinstance(item_data, dict):
+ # If it's not a dict, try to convert or skip
+ if isinstance(item_data, str):
+ # If it's a string, create a simple dict
+ item_data = {'title': item_data, 'description': item_data}
+ else:
+ # Try to convert to dict or skip
+ continue
+
+ # Parse timestamp if available - support nested paths
+ published = None
+ if timestamp_field:
+ ts_value = get_nested_value(item_data, timestamp_field)
+ if ts_value:
+ try:
+ if isinstance(ts_value, (int, float)):
+ published = datetime.fromtimestamp(ts_value, tz=timezone.utc)
+ elif isinstance(ts_value, str):
+ # Try Microsoft date format first
+ if ts_value.startswith('/Date('):
+ published = self._parse_microsoft_date(ts_value)
+ else:
+ # Try ISO format
+ try:
+ published = datetime.fromisoformat(ts_value.replace('Z', '+00:00'))
+ except ValueError:
+ # Try common formats
+ for fmt in ['%Y-%m-%dT%H:%M:%S', '%Y-%m-%d %H:%M:%S', '%Y-%m-%d']:
+ try:
+ published = datetime.strptime(ts_value, fmt)
+ if published.tzinfo is None:
+ published = published.replace(tzinfo=timezone.utc)
+ break
+ except ValueError:
+ continue
+ except Exception:
+ pass
+
+ # Get description - support nested paths
+ description = ''
+ if description_field:
+ desc_value = get_nested_value(item_data, description_field)
+ if desc_value:
+ description = str(desc_value)
+
+ items.append({
+ 'title': get_nested_value(item_data, title_field, 'Untitled'),
+ 'description': description,
+ 'link': item_data.get('link', '') if isinstance(item_data, dict) else '',
+ 'published': published,
+ 'raw': item_data # Store raw data for format string access
+ })
+
+ # Apply sorting if configured
+ if sort_config:
+ items = self._sort_items_preview(items, sort_config)
+
+ # Apply filter if configured
+ if filter_config:
+ items = [item for item in items if self._should_include_item(item, filter_config)]
+
+ # Limit to first 3 items after filtering
+ items = items[:3]
+
+ # Format items using output format
+ formatted_items = []
+ for item in items:
+ formatted = self._format_feed_item(item, output_format, feed_name='')
+ formatted_items.append({
+ 'original': item,
+ 'formatted': formatted
+ })
+
+ return formatted_items
+
+ except Exception as e:
+ self.logger.error(f"Error previewing feed: {e}")
+ raise
+
+ def _should_include_item(self, item: Dict[str, Any], filter_config: dict) -> bool:
+ """Check if an item should be included based on filter configuration (standalone version for preview)"""
+ import json
+ import re
+
+ if not filter_config:
+ return True
+
+ try:
+ filter_config_dict = json.loads(filter_config) if isinstance(filter_config, str) else filter_config
+ except (json.JSONDecodeError, TypeError):
+ return True
+
+ conditions = filter_config_dict.get('conditions', [])
+ if not conditions:
+ return True
+
+ logic = filter_config_dict.get('logic', 'AND').upper()
+
+ # Get raw data for field access
+ raw_data = item.get('raw', {})
+
+ # Helper to get nested values
+ def get_nested_value(data, path, default=''):
+ if not path or not data:
+ return default
+ parts = path.split('.')
+ value = data
+ for part in parts:
+ if isinstance(value, dict):
+ value = value.get(part)
+ elif isinstance(value, list):
+ try:
+ idx = int(part)
+ if 0 <= idx < len(value):
+ value = value[idx]
+ else:
+ return default
+ except (ValueError, TypeError):
+ return default
+ else:
+ return default
+ if value is None:
+ return default
+ return value if value is not None else default
+
+ # Evaluate each condition
+ results = []
+ for condition in conditions:
+ field_path = condition.get('field')
+ operator = condition.get('operator', 'equals')
+
+ if not field_path:
+ continue
+
+ # Get field value using nested access
+ field_value = get_nested_value(raw_data, field_path, '')
+ if not field_value and field_path.startswith('raw.'):
+ field_value = get_nested_value(raw_data, field_path[4:], '')
+
+ if not field_value:
+ field_value = get_nested_value(item, field_path, '')
+
+ # Convert to string for comparison
+ field_value_str = str(field_value).lower() if field_value is not None else ''
+
+ # Evaluate condition
+ result = False
+ if operator == 'equals':
+ compare_value = str(condition.get('value', '')).lower()
+ result = field_value_str == compare_value
+ elif operator == 'not_equals':
+ compare_value = str(condition.get('value', '')).lower()
+ result = field_value_str != compare_value
+ elif operator == 'in':
+ values = [str(v).lower() for v in condition.get('values', [])]
+ result = field_value_str in values
+ elif operator == 'not_in':
+ values = [str(v).lower() for v in condition.get('values', [])]
+ result = field_value_str not in values
+ elif operator == 'matches':
+ pattern = condition.get('pattern', '')
+ if pattern:
+ try:
+ result = bool(re.search(pattern, str(field_value), re.IGNORECASE))
+ except re.error:
+ result = False
+ elif operator == 'not_matches':
+ pattern = condition.get('pattern', '')
+ if pattern:
+ try:
+ result = not bool(re.search(pattern, str(field_value), re.IGNORECASE))
+ except re.error:
+ result = True
+ elif operator == 'contains':
+ compare_value = str(condition.get('value', '')).lower()
+ result = compare_value in field_value_str
+ elif operator == 'not_contains':
+ compare_value = str(condition.get('value', '')).lower()
+ result = compare_value not in field_value_str
+ else:
+ result = True # Default to allowing if operator is unknown
+
+ results.append(result)
+
+ # Apply logic (AND or OR)
+ if logic == 'OR':
+ return any(results)
+ else: # AND (default)
+ return all(results)
+
+ def _parse_microsoft_date(self, date_str: str) -> Optional[datetime]:
+ """Parse Microsoft JSON date format: /Date(timestamp-offset)/"""
+ import re
+ from datetime import timezone
+
+ if not date_str or not isinstance(date_str, str):
+ return None
+
+ # Match /Date(timestamp-offset)/ format
+ match = re.match(r'/Date\((\d+)([+-]\d+)?\)/', date_str)
+ if match:
+ timestamp_ms = int(match.group(1))
+ offset_str = match.group(2) if match.group(2) else '+0000'
+
+ # Convert milliseconds to seconds
+ timestamp = timestamp_ms / 1000.0
+
+ # Parse offset (format: +0800 or -0800)
+ try:
+ offset_hours = int(offset_str[:3])
+ offset_mins = int(offset_str[3:5])
+ offset_seconds = (offset_hours * 3600) + (offset_mins * 60)
+ if offset_str[0] == '-':
+ offset_seconds = -offset_seconds
+
+ # Create timezone-aware datetime
+ tz = timezone.utc
+ if offset_seconds != 0:
+ from datetime import timedelta
+ tz = timezone(timedelta(seconds=offset_seconds))
+
+ return datetime.fromtimestamp(timestamp, tz=tz)
+ except (ValueError, IndexError):
+ # Fallback to UTC if offset parsing fails
+ return datetime.fromtimestamp(timestamp, tz=timezone.utc)
+
+ return None
+
+ def _sort_items_preview(self, items: List[Dict[str, Any]], sort_config: dict) -> List[Dict[str, Any]]:
+ """Sort items based on sort configuration (standalone version for preview)"""
+ if not sort_config or not items:
+ return items
+
+ field_path = sort_config.get('field')
+ order = sort_config.get('order', 'desc').lower()
+
+ if not field_path:
+ return items
+
+ # Helper to get nested values
+ def get_nested_value(data, path, default=''):
+ if not path or not data:
+ return default
+ parts = path.split('.')
+ value = data
+ for part in parts:
+ if isinstance(value, dict):
+ value = value.get(part)
+ elif isinstance(value, list):
+ try:
+ idx = int(part)
+ if 0 <= idx < len(value):
+ value = value[idx]
+ else:
+ return default
+ except (ValueError, TypeError):
+ return default
+ else:
+ return default
+ if value is None:
+ return default
+ return value if value is not None else default
+
+ def get_sort_value(item):
+ """Get the sort value for an item"""
+ # Try raw data first
+ raw_data = item.get('raw', {})
+ value = get_nested_value(raw_data, field_path, '')
+
+ if not value and field_path.startswith('raw.'):
+ value = get_nested_value(raw_data, field_path[4:], '')
+
+ if not value:
+ value = get_nested_value(item, field_path, '')
+
+ # Handle Microsoft date format
+ if isinstance(value, str) and value.startswith('/Date('):
+ dt = self._parse_microsoft_date(value)
+ if dt:
+ return dt.timestamp()
+
+ # Handle datetime objects
+ if isinstance(value, datetime):
+ return value.timestamp()
+
+ # Handle numeric values
+ if isinstance(value, (int, float)):
+ return float(value)
+
+ # Handle string timestamps
+ if isinstance(value, str):
+ # Try to parse as ISO format
+ try:
+ dt = datetime.fromisoformat(value.replace('Z', '+00:00'))
+ return dt.timestamp()
+ except ValueError:
+ pass
+
+ # Try common date formats
+ for fmt in ['%Y-%m-%dT%H:%M:%S', '%Y-%m-%d %H:%M:%S', '%Y-%m-%d']:
+ try:
+ dt = datetime.strptime(value, fmt)
+ return dt.timestamp()
+ except ValueError:
+ continue
+
+ # For strings, use lexicographic comparison
+ return str(value)
+
+ # Sort items
+ try:
+ sorted_items = sorted(items, key=get_sort_value, reverse=(order == 'desc'))
+ return sorted_items
+ except Exception as e:
+ self.logger.warning(f"Error sorting items in preview: {e}")
+ return items
+
+ def _format_feed_item(self, item: Dict[str, Any], format_str: str, feed_name: str = '') -> str:
+ """Format a feed item using the output format (standalone version)"""
+ import html
+ import re
+ from datetime import datetime, timezone
+
+ # Extract field values
+ title = item.get('title', 'Untitled')
+ body = item.get('description', '') or item.get('body', '')
+
+ # Clean HTML from body if present
+ if body:
+ body = html.unescape(body)
+ # Convert line break tags to newlines before stripping other HTML
+ # Handle , , , , etc.
+ body = re.sub(r' ', '\n', body, flags=re.IGNORECASE)
+ # Convert paragraph tags to newlines (with spacing)
+ body = re.sub(r'
', '\n\n', body, flags=re.IGNORECASE)
+ body = re.sub(r'
]*>', '', body, flags=re.IGNORECASE)
+ # Remove remaining HTML tags
+ body = re.sub(r'<[^>]+>', '', body)
+ # Clean up whitespace (preserve intentional line breaks)
+ # Replace multiple newlines with double newline, then normalize spaces within lines
+ body = re.sub(r'\n\s*\n\s*\n+', '\n\n', body) # Multiple newlines -> double newline
+ lines = body.split('\n')
+ body = '\n'.join(' '.join(line.split()) for line in lines) # Normalize spaces per line
+ body = body.strip()
+
+ link = item.get('link', '')
+ published = item.get('published')
+
+ # Format timestamp
+ date_str = ""
+ if published:
+ try:
+ if published.tzinfo:
+ now = datetime.now(timezone.utc)
+ else:
+ now = datetime.now()
+
+ diff = now - published
+ minutes = int(diff.total_seconds() / 60)
+
+ if minutes < 1:
+ date_str = "now"
+ elif minutes < 60:
+ date_str = f"{minutes}m ago"
+ elif minutes < 1440:
+ hours = minutes // 60
+ mins = minutes % 60
+ date_str = f"{hours}h {mins}m ago"
+ else:
+ days = minutes // 1440
+ date_str = f"{days}d ago"
+ except Exception:
+ pass
+
+ # Choose emoji
+ emoji = "đĸ"
+ feed_name_lower = feed_name.lower()
+ if 'emergency' in feed_name_lower or 'alert' in feed_name_lower:
+ emoji = "đ¨"
+ elif 'warning' in feed_name_lower:
+ emoji = "â ī¸"
+ elif 'info' in feed_name_lower or 'news' in feed_name_lower:
+ emoji = "âšī¸"
+
+ # Build replacements
+ replacements = {
+ 'title': title,
+ 'body': body,
+ 'date': date_str,
+ 'link': link,
+ 'emoji': emoji
+ }
+
+ # Get raw API data if available (for preview, we don't have raw data, so this will be empty)
+ raw_data = item.get('raw', {})
+
+ # Helper to get nested values
+ def get_nested_value(data, path, default=''):
+ if not path or not data:
+ return default
+ parts = path.split('.')
+ value = data
+ for part in parts:
+ if isinstance(value, dict):
+ value = value.get(part)
+ elif isinstance(value, list):
+ try:
+ idx = int(part)
+ if 0 <= idx < len(value):
+ value = value[idx]
+ else:
+ return default
+ except (ValueError, TypeError):
+ return default
+ else:
+ return default
+ if value is None:
+ return default
+ return value if value is not None else default
+
+ # Apply shortening, parsing, and conditional functions
+ def apply_shortening(text: str, function: str) -> str:
+ if not text:
+ return ""
+
+ if function.startswith('truncate:'):
+ try:
+ max_len = int(function.split(':', 1)[1])
+ if len(text) <= max_len:
+ return text
+ return text[:max_len] + "..."
+ except (ValueError, IndexError):
+ return text
+ elif function.startswith('word_wrap:'):
+ try:
+ max_len = int(function.split(':', 1)[1])
+ if len(text) <= max_len:
+ return text
+ truncated = text[:max_len]
+ last_space = truncated.rfind(' ')
+ if last_space > max_len * 0.7:
+ return truncated[:last_space] + "..."
+ return truncated + "..."
+ except (ValueError, IndexError):
+ return text
+ elif function.startswith('first_words:'):
+ try:
+ num_words = int(function.split(':', 1)[1])
+ words = text.split()
+ if len(words) <= num_words:
+ return text
+ return ' '.join(words[:num_words]) + "..."
+ except (ValueError, IndexError):
+ return text
+ elif function.startswith('regex:'):
+ try:
+ # Parse regex pattern and optional group number
+ # Format: regex:pattern:group or regex:pattern
+ # Need to handle patterns that contain colons, so split from the right
+ remaining = function[6:] # Skip 'regex:' prefix
+
+ # Try to find the last colon that's followed by a number (the group number)
+ # Look for pattern like :N at the end
+ last_colon_idx = remaining.rfind(':')
+ pattern = remaining
+ group_num = None
+
+ if last_colon_idx > 0:
+ # Check if what's after the last colon is a number
+ potential_group = remaining[last_colon_idx + 1:]
+ if potential_group.isdigit():
+ pattern = remaining[:last_colon_idx]
+ group_num = int(potential_group)
+
+ if not pattern:
+ return text
+
+ # Apply regex
+ match = re.search(pattern, text, re.IGNORECASE | re.DOTALL)
+ if match:
+ if group_num is not None:
+ # Use specified group (0 = whole match, 1 = first group, etc.)
+ if 0 <= group_num <= len(match.groups()):
+ return match.group(group_num) if group_num > 0 else match.group(0)
+ else:
+ # Use first capture group if available, otherwise whole match
+ if match.groups():
+ return match.group(1)
+ else:
+ return match.group(0)
+ return "" # No match found
+ except (ValueError, IndexError, re.error) as e:
+ # Silently fail on regex errors in preview
+ return text
+ elif function.startswith('if_regex:'):
+ try:
+ # Parse: if_regex:pattern:then:else
+ # Split by ':' but need to handle regex patterns that contain ':'
+ parts = function[9:].split(':', 2) # Skip 'if_regex:' prefix, split into [pattern, then, else]
+ if len(parts) < 3:
+ return text
+
+ pattern = parts[0]
+ then_value = parts[1]
+ else_value = parts[2]
+
+ if not pattern:
+ return text
+
+ # Check if pattern matches
+ match = re.search(pattern, text, re.IGNORECASE | re.DOTALL)
+ if match:
+ return then_value
+ else:
+ return else_value
+ except (ValueError, IndexError, re.error) as e:
+ # Silently fail on regex errors in preview
+ return text
+ elif function.startswith('switch:'):
+ try:
+ # Parse: switch:value1:result1:value2:result2:...:default
+ # Example: switch:highest:đ´:high:đ :medium:đĄ:low:âĒ:âĒ
+ parts = function[7:].split(':') # Skip 'switch:' prefix
+ if len(parts) < 2:
+ return text
+
+ # Pairs of value:result, last one is default
+ text_lower = text.lower().strip()
+ for i in range(0, len(parts) - 1, 2):
+ if i + 1 < len(parts):
+ value = parts[i].lower()
+ result = parts[i + 1]
+ if text_lower == value:
+ return result
+
+ # Return last part as default if no match
+ return parts[-1] if parts else text
+ except (ValueError, IndexError) as e:
+ # Silently fail on switch errors in preview
+ return text
+ elif function.startswith('regex_cond:'):
+ try:
+ # Parse: regex_cond:extract_pattern:check_pattern:then:group
+ parts = function[11:].split(':', 3) # Skip 'regex_cond:' prefix
+ if len(parts) < 4:
+ return text
+
+ extract_pattern = parts[0]
+ check_pattern = parts[1]
+ then_value = parts[2]
+ else_group = int(parts[3]) if parts[3].isdigit() else 1
+
+ if not extract_pattern:
+ return text
+
+ # Extract using extract_pattern
+ match = re.search(extract_pattern, text, re.IGNORECASE | re.DOTALL)
+ if match:
+ # Get the captured group
+ if match.groups():
+ extracted = match.group(else_group) if else_group <= len(match.groups()) else match.group(1)
+ # Strip whitespace from extracted text
+ extracted = extracted.strip()
+ else:
+ extracted = match.group(0).strip()
+
+ # Check if extracted text matches check_pattern (exact match or contains)
+ if check_pattern:
+ # Try exact match first, then substring match
+ if extracted.lower() == check_pattern.lower() or re.search(check_pattern, extracted, re.IGNORECASE):
+ return then_value
+
+ return extracted
+ return "" # No match found
+ except (ValueError, IndexError, re.error) as e:
+ # Silently fail on regex errors in preview
+ return text
+ return text
+
+ # Process format string
+ def replace_placeholder(match):
+ content = match.group(1)
+ if '|' in content:
+ field_name, function = content.split('|', 1)
+ field_name = field_name.strip()
+ function = function.strip()
+
+ # Check if it's a raw field access
+ if field_name.startswith('raw.'):
+ value = str(get_nested_value(raw_data, field_name[4:], ''))
+ else:
+ value = replacements.get(field_name, '')
+
+ return apply_shortening(value, function)
+ else:
+ field_name = content.strip()
+
+ # Check if it's a raw field access
+ if field_name.startswith('raw.'):
+ value = get_nested_value(raw_data, field_name[4:], '')
+ if value is None:
+ return ''
+ elif isinstance(value, (dict, list)):
+ try:
+ import json
+ return json.dumps(value)
+ except Exception:
+ return str(value)
+ else:
+ return str(value)
+ else:
+ return replacements.get(field_name, '')
+
+ message = re.sub(r'\{([^}]+)\}', replace_placeholder, format_str)
+
+ # Final truncation (130 char limit)
+ max_length = 130
+ if len(message) > max_length:
+ lines = message.split('\n')
+ if len(lines) > 1:
+ total_length = sum(len(line) + 1 for line in lines[:-1])
+ remaining = max_length - total_length - 3
+ if remaining > 20:
+ lines[-1] = lines[-1][:remaining] + "..."
+ message = '\n'.join(lines)
+ else:
+ message = message[:max_length - 3] + "..."
+ else:
+ message = message[:max_length - 3] + "..."
+
+ return message
+
def _get_bot_uptime(self):
"""Get bot uptime in seconds from database"""
try:
@@ -1928,6 +3508,92 @@ class BotDataViewer:
self.logger.debug(f"Could not get bot start time from database: {e}")
return 0
+ def _add_channel_for_web(self, channel_idx, channel_name, channel_key_hex=None):
+ """
+ Add a channel by queuing it in the database for the bot to process
+
+ Args:
+ channel_idx: Channel index (0-39)
+ channel_name: Channel name (with or without # prefix)
+ channel_key_hex: Optional hex key for custom channels (32 chars)
+
+ Returns:
+ dict with 'success' and optional 'error' key
+ """
+ try:
+ conn = self._get_db_connection()
+ cursor = conn.cursor()
+
+ # Insert operation into queue
+ cursor.execute('''
+ INSERT INTO channel_operations
+ (operation_type, channel_idx, channel_name, channel_key_hex, status)
+ VALUES (?, ?, ?, ?, 'pending')
+ ''', ('add', channel_idx, channel_name, channel_key_hex))
+
+ operation_id = cursor.lastrowid
+ conn.commit()
+ conn.close()
+
+ self.logger.info(f"Queued channel add operation: {channel_name} at index {channel_idx} (operation_id: {operation_id})")
+
+ # Return immediately with operation_id - let frontend poll for status
+ return {
+ 'success': True,
+ 'pending': True,
+ 'operation_id': operation_id,
+ 'message': 'Channel operation queued successfully'
+ }
+
+ except Exception as e:
+ self.logger.error(f"Error in _add_channel_for_web: {e}")
+ return {
+ 'success': False,
+ 'error': str(e)
+ }
+
+ def _remove_channel_for_web(self, channel_idx):
+ """
+ Remove a channel by queuing it in the database for the bot to process
+
+ Args:
+ channel_idx: Channel index to remove
+
+ Returns:
+ dict with 'success' and optional 'error' key
+ """
+ try:
+ conn = self._get_db_connection()
+ cursor = conn.cursor()
+
+ # Insert operation into queue
+ cursor.execute('''
+ INSERT INTO channel_operations
+ (operation_type, channel_idx, status)
+ VALUES (?, ?, 'pending')
+ ''', ('remove', channel_idx))
+
+ operation_id = cursor.lastrowid
+ conn.commit()
+ conn.close()
+
+ self.logger.info(f"Queued channel remove operation: index {channel_idx} (operation_id: {operation_id})")
+
+ # Return immediately with operation_id - let frontend poll for status
+ return {
+ 'success': True,
+ 'pending': True,
+ 'operation_id': operation_id,
+ 'message': 'Channel operation queued successfully'
+ }
+
+ except Exception as e:
+ self.logger.error(f"Error in _remove_channel_for_web: {e}")
+ return {
+ 'success': False,
+ 'error': str(e)
+ }
+
def run(self, host='127.0.0.1', port=8080, debug=False):
"""Run the modern web viewer"""
self.logger.info(f"Starting modern web viewer on {host}:{port}")
diff --git a/modules/web_viewer/templates/base.html b/modules/web_viewer/templates/base.html
index f11409a..16559f0 100644
--- a/modules/web_viewer/templates/base.html
+++ b/modules/web_viewer/templates/base.html
@@ -146,11 +146,25 @@
Cache
+ {% if greeter_enabled %}