Enhance configuration and database management for feed and channel operations. Add channel refresh interval setting to config.ini.example, implement feed manager functionality with new database tables for feed subscriptions and activity tracking, and improve greeter command logic with human greeting detection and Levenshtein distance matching for user greetings. Update web viewer to include feed management pages and integrate global template variables for better user experience.

This commit is contained in:
agessaman
2025-12-06 10:18:12 -08:00
parent 67e8f68c1a
commit c151fba7a5
16 changed files with 6661 additions and 144 deletions
+147 -68
View File
@@ -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
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
+413
View File
@@ -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 <subcommand> [arguments]` (DM only)
### Available Commands
- `feed subscribe <rss|api> <url> <channel> [name] [api_config]` - Subscribe to a feed
- `feed unsubscribe <id|url> [channel]` - Unsubscribe from a feed (by ID or URL)
- `feed list [channel]` - List all feed subscriptions (optionally filtered by channel)
- `feed status <id>` - Show detailed status for a feed
- `feed enable <id>` - Enable a feed subscription
- `feed disable <id>` - Disable a feed subscription
- `feed update <id> [interval_seconds]` - Update feed settings
- `feed test <url>` - 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
+414
View File
@@ -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 <idx> "" <empty_secret_hex>
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
+58 -13
View File
@@ -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
+417
View File
@@ -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 <url> <channel> [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 <rss|api> <url> <channel> [name]
feed unsubscribe <id|url> <channel>
feed list [channel]
feed status <id>
feed test <url>
feed enable <id>
feed disable <id>
feed update <id> [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 <rss|api> <url> <channel> [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 <id|url> [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 <id>")
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 <url>")
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'} <id>")
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 <id> [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
+342 -41
View File
@@ -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."
+18
View File
@@ -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
+123
View File
@@ -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")
File diff suppressed because it is too large Load Diff
+33 -10
View File
@@ -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)
+185
View File
@@ -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}")
+1678 -12
View File
File diff suppressed because it is too large Load Diff
+14
View File
@@ -146,11 +146,25 @@
<i class="fas fa-database"></i> Cache
</a>
</li>
{% if greeter_enabled %}
<li class="nav-item">
<a class="nav-link" href="/greeter">
<i class="fas fa-hand-sparkles"></i> Greeter
</a>
</li>
{% endif %}
{% if feed_manager_enabled %}
<li class="nav-item">
<a class="nav-link" href="/feeds">
<i class="fas fa-rss"></i> Feeds
</a>
</li>
{% endif %}
<li class="nav-item">
<a class="nav-link" href="/radio">
<i class="fas fa-broadcast-tower"></i> Radio
</a>
</li>
</ul>
<!-- Connection Status -->
+848
View File
@@ -0,0 +1,848 @@
{% extends "base.html" %}
{% block title %}Feed Management - MeshCore Bot{% endblock %}
{% block content %}
<div class="container-fluid">
<div class="row">
<div class="col-12">
<h1 class="mb-4">
<i class="fas fa-rss"></i> Feed Management
</h1>
<!-- Statistics Cards -->
<div class="row mb-4">
<div class="col-md-3">
<div class="card">
<div class="card-body">
<h5 class="card-title">Total Subscriptions</h5>
<h2 id="total-subscriptions">-</h2>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card">
<div class="card-body">
<h5 class="card-title">Active Feeds</h5>
<h2 id="active-feeds">-</h2>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card">
<div class="card-body">
<h5 class="card-title">Items (24h)</h5>
<h2 id="items-24h">-</h2>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card">
<div class="card-body">
<h5 class="card-title">Active Errors</h5>
<h2 id="active-errors">-</h2>
</div>
</div>
</div>
</div>
<!-- Feed List -->
<div class="card">
<div class="card-header d-flex justify-content-between align-items-center">
<h5 class="mb-0">Feed Subscriptions</h5>
<button class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#addFeedModal">
<i class="fas fa-plus"></i> Add Feed
</button>
</div>
<div class="card-body">
<div class="table-responsive">
<table class="table table-striped table-hover" id="feedsTable">
<thead>
<tr>
<th>ID</th>
<th>Name</th>
<th>Type</th>
<th>Channel</th>
<th>Status</th>
<th>Last Check</th>
<th>Items</th>
<th>Errors</th>
<th>Actions</th>
</tr>
</thead>
<tbody id="feedsTableBody">
<tr>
<td colspan="9" class="text-center">Loading...</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Add/Edit Feed Modal -->
<div class="modal fade" id="addFeedModal" tabindex="-1">
<div class="modal-dialog modal-xl">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="feedModalTitle">Add Feed Subscription</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body" style="max-height: calc(100vh - 200px); overflow-y: auto;">
<form id="addFeedForm">
<input type="hidden" id="feedId" name="feed_id">
<!-- Basic Settings Row -->
<div class="row mb-3">
<div class="col-md-3">
<label class="form-label">Feed Type</label>
<select class="form-select" id="feedType" name="feed_type" required>
<option value="rss">RSS Feed</option>
<option value="api">API Feed</option>
</select>
</div>
<div class="col-md-6">
<label class="form-label">Feed URL</label>
<input type="url" class="form-control" id="feedUrl" name="feed_url" required>
</div>
<div class="col-md-3">
<label class="form-label">Check Interval (seconds)</label>
<input type="number" class="form-control" id="checkInterval" name="check_interval_seconds" value="300" min="60">
</div>
</div>
<!-- Channel and Name Row -->
<div class="row mb-3">
<div class="col-md-6">
<label class="form-label">Channel</label>
<div class="input-group">
<select class="form-select" id="channelSelect" name="channel_name" required>
<option value="">Select channel...</option>
</select>
<button type="button" class="btn btn-outline-secondary" id="createChannelBtn">
<i class="fas fa-plus"></i> New
</button>
</div>
<div id="newChannelGroup" style="display: none;" class="mt-2">
<input type="text" class="form-control form-control-sm" id="newChannelName" placeholder="#channelname">
<small class="form-text text-muted">Channel will be created as hashtag channel</small>
</div>
</div>
<div class="col-md-3">
<label class="form-label">Feed Name <small class="text-muted">(Optional)</small></label>
<input type="text" class="form-control" id="feedName" name="feed_name" placeholder="My Feed">
</div>
<div class="col-md-3">
<label class="form-label">Send Interval (seconds)</label>
<input type="number" class="form-control" id="messageSendInterval" name="message_send_interval_seconds" value="2.0" min="0.5" step="0.1">
<small class="form-text text-muted">Time between messages</small>
</div>
</div>
<!-- Output Format Row -->
<div class="row mb-3">
<div class="col-md-8">
<label class="form-label">
Output Format
<button type="button" class="btn btn-sm btn-link p-0 ms-2" data-bs-toggle="collapse" data-bs-target="#formatHelp" aria-expanded="false">
<i class="fas fa-question-circle"></i> Help
</button>
</label>
<textarea class="form-control font-monospace" id="outputFormat" name="output_format" rows="5" placeholder="{emoji} {body|truncate:100} - {date}\n{link|truncate:50}"></textarea>
<div class="collapse mt-2" id="formatHelp">
<div class="card card-body bg-light small">
<strong>Placeholders:</strong> {title}, {body}, {date}, {link}, {emoji}<br>
<strong>API Fields:</strong> {raw.field} or {raw.nested.field}<br>
<strong>Shortening:</strong> {field|truncate:N}, {field|word_wrap:N}, {field|first_words:N}<br>
<strong>Regex:</strong> {field|regex:pattern} or {field|regex:pattern:group}<br>
<strong>Conditional:</strong> {field|if_regex:pattern:then:else}<br>
<strong>Switch:</strong> {field|switch:value1:result1:value2:result2:...:default}<br>
<strong>Extract & Check:</strong> {field|regex_cond:extract_pattern:check_pattern:then:group}
</div>
</div>
</div>
<div class="col-md-4">
<label class="form-label">&nbsp;</label>
<div class="d-grid">
<button type="button" class="btn btn-outline-primary" id="previewBtn" title="Preview format with live feed">
<i class="fas fa-eye"></i> Preview Format
</button>
</div>
<div id="previewResults" style="display: none;" class="mt-3">
<div class="card">
<div class="card-header py-2">
<h6 class="mb-0 small">Preview (first 3 items)</h6>
</div>
<div class="card-body p-2" id="previewContent" style="max-height: 200px; overflow-y: auto; font-size: 0.85rem;">
<div class="spinner-border spinner-border-sm" role="status">
<span class="visually-hidden">Loading...</span>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Advanced Options Accordion -->
<div class="accordion mb-3" id="advancedOptions">
<div class="accordion-item" id="apiConfigGroup" style="display: none;">
<h2 class="accordion-header">
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#apiConfigCollapse">
<i class="fas fa-cog me-2"></i> API Configuration
</button>
</h2>
<div id="apiConfigCollapse" class="accordion-collapse collapse" data-bs-parent="#advancedOptions">
<div class="accordion-body">
<textarea class="form-control font-monospace" id="apiConfig" name="api_config" rows="8" placeholder='{
"method": "GET",
"headers": {},
"params": {
"AccessCode": "YOUR_ACCESS_CODE"
},
"response_parser": {
"items_path": "",
"id_field": "AlertID",
"title_field": "HeadlineDescription",
"description_field": "ExtendedDescription",
"timestamp_field": "LastUpdatedTime"
}
}'></textarea>
<small class="form-text text-muted mt-2 d-block">
Configure API request and parsing. Use <code>params</code> for query parameters (e.g., AccessCode for WSDOT) or <code>headers</code> for HTTP headers.
</small>
</div>
</div>
</div>
<div class="accordion-item">
<h2 class="accordion-header">
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#filterConfigCollapse">
<i class="fas fa-filter me-2"></i> Filter Configuration <small class="text-muted ms-2">(Optional)</small>
</button>
</h2>
<div id="filterConfigCollapse" class="accordion-collapse collapse" data-bs-parent="#advancedOptions">
<div class="accordion-body">
<textarea class="form-control font-monospace" id="filterConfig" name="filter_config" rows="8" placeholder='{
"conditions": [
{
"field": "raw.Priority",
"operator": "in",
"values": ["highest", "high"]
},
{
"field": "raw.EventStatus",
"operator": "equals",
"value": "open"
}
],
"logic": "AND"
}'></textarea>
<small class="form-text text-muted mt-2 d-block">
Only send items that match these conditions. Leave empty to send all items.<br>
<strong>Operators:</strong> equals, not_equals, in, not_in, matches (regex), not_matches, contains, not_contains<br>
<strong>Logic:</strong> AND (all conditions) or OR (any condition)<br>
<strong>Fields:</strong> Use raw.field or raw.nested.field for API fields
</small>
</div>
</div>
</div>
<div class="accordion-item">
<h2 class="accordion-header">
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#sortConfigCollapse">
<i class="fas fa-sort me-2"></i> Sort Configuration <small class="text-muted ms-2">(Optional)</small>
</button>
</h2>
<div id="sortConfigCollapse" class="accordion-collapse collapse" data-bs-parent="#advancedOptions">
<div class="accordion-body">
<textarea class="form-control font-monospace" id="sortConfig" name="sort_config" rows="4" placeholder='{
"field": "raw.LastUpdatedTime",
"order": "desc"
}'></textarea>
<small class="form-text text-muted mt-2 d-block">
Sort items before processing. Leave empty to use default order (oldest first).<br>
<strong>Field:</strong> Field path to sort by (e.g., raw.LastUpdatedTime, raw.Priority, published)<br>
<strong>Order:</strong> asc (ascending) or desc (descending)<br>
<strong>Note:</strong> Supports Microsoft date format /Date(timestamp-offset)/ (e.g., WSDOT API)
</small>
</div>
</div>
</div>
</div>
</form>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-primary" id="saveFeedBtn">Save</button>
</div>
</div>
</div>
</div>
<!-- Feed Details Modal -->
<div class="modal fade" id="feedDetailsModal" tabindex="-1">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Feed Details</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body" id="feedDetailsContent">
Loading...
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
</div>
</div>
</div>
</div>
{% endblock %}
{% block extra_js %}
<script>
class FeedManager {
constructor() {
this.feeds = [];
this.channels = [];
this.initialize();
}
async initialize() {
await this.loadChannels();
await this.loadFeeds();
await this.loadStatistics();
this.setupEventHandlers();
// Auto-refresh every 30 seconds
setInterval(() => this.loadFeeds(), 30000);
setInterval(() => this.loadStatistics(), 60000);
}
async loadChannels() {
try {
const response = await fetch('/api/channels');
const data = await response.json();
this.channels = data.channels || [];
this.updateChannelSelect();
} catch (error) {
console.error('Error loading channels:', error);
}
}
async loadFeeds() {
try {
const response = await fetch('/api/feeds');
const data = await response.json();
this.feeds = data.feeds || [];
this.renderFeeds();
} catch (error) {
console.error('Error loading feeds:', error);
this.showError('Failed to load feeds: ' + error.message);
}
}
async loadStatistics() {
try {
const response = await fetch('/api/feeds/stats');
const data = await response.json();
document.getElementById('total-subscriptions').textContent = data.total_subscriptions || 0;
document.getElementById('active-feeds').textContent = data.enabled_subscriptions || 0;
document.getElementById('items-24h').textContent = data.items_24h || 0;
document.getElementById('active-errors').textContent = data.active_errors || 0;
} catch (error) {
console.error('Error loading statistics:', error);
}
}
updateChannelSelect() {
const select = document.getElementById('channelSelect');
select.innerHTML = '<option value="">Select channel...</option>';
this.channels.forEach(channel => {
const option = document.createElement('option');
option.value = channel.name || channel.channel_name;
option.textContent = channel.name || channel.channel_name;
select.appendChild(option);
});
}
renderFeeds() {
const tbody = document.getElementById('feedsTableBody');
if (this.feeds.length === 0) {
tbody.innerHTML = '<tr><td colspan="9" class="text-center">No feed subscriptions</td></tr>';
return;
}
tbody.innerHTML = this.feeds.map(feed => {
const statusBadge = feed.enabled
? '<span class="badge bg-success">Enabled</span>'
: '<span class="badge bg-secondary">Disabled</span>';
// Parse timestamp and convert to local time
let lastCheck = 'Never';
if (feed.last_check_time) {
try {
// Handle ISO format with timezone, or SQLite format (treat as UTC)
let dateStr = feed.last_check_time;
// If it's SQLite format (YYYY-MM-DD HH:MM:SS), append 'Z' to indicate UTC
if (/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/.test(dateStr)) {
dateStr = dateStr.replace(' ', 'T') + 'Z';
}
const date = new Date(dateStr);
if (!isNaN(date.getTime())) {
lastCheck = date.toLocaleString();
}
} catch (e) {
lastCheck = feed.last_check_time; // Fallback to raw value
}
}
return `
<tr>
<td>${feed.id}</td>
<td>${feed.feed_name || feed.feed_url.substring(0, 30)}</td>
<td><span class="badge bg-info">${feed.feed_type.toUpperCase()}</span></td>
<td>${feed.channel_name}</td>
<td>${statusBadge}</td>
<td>${lastCheck}</td>
<td>${feed.item_count || 0}</td>
<td>${feed.error_count > 0 ? `<span class="badge bg-danger">${feed.error_count}</span>` : '0'}</td>
<td>
<button class="btn btn-sm btn-info" onclick="feedManager.viewFeed(${feed.id})" title="View Details">
<i class="fas fa-eye"></i>
</button>
<button class="btn btn-sm btn-primary" onclick="feedManager.editFeed(${feed.id})" title="Edit">
<i class="fas fa-edit"></i>
</button>
<button class="btn btn-sm btn-${feed.enabled ? 'warning' : 'success'}" onclick="feedManager.toggleFeed(${feed.id}, ${!feed.enabled})" title="${feed.enabled ? 'Disable' : 'Enable'}">
<i class="fas fa-${feed.enabled ? 'pause' : 'play'}"></i>
</button>
<button class="btn btn-sm btn-danger" onclick="feedManager.deleteFeed(${feed.id})" title="Delete">
<i class="fas fa-trash"></i>
</button>
</td>
</tr>
`;
}).join('');
}
setupEventHandlers() {
// Feed type change
document.getElementById('feedType').addEventListener('change', (e) => {
const apiConfigGroup = document.getElementById('apiConfigGroup');
apiConfigGroup.style.display = e.target.value === 'api' ? 'block' : 'none';
// If switching to API, expand the API config accordion
if (e.target.value === 'api') {
const apiCollapseEl = document.getElementById('apiConfigCollapse');
if (apiCollapseEl) {
const apiCollapse = new bootstrap.Collapse(apiCollapseEl, {show: true});
}
}
});
// Create channel button
document.getElementById('createChannelBtn').addEventListener('click', () => {
const newChannelGroup = document.getElementById('newChannelGroup');
newChannelGroup.style.display = newChannelGroup.style.display === 'none' ? 'block' : 'none';
});
// Save feed
document.getElementById('saveFeedBtn').addEventListener('click', () => this.saveFeed());
// Preview button
document.getElementById('previewBtn').addEventListener('click', () => this.previewFormat());
// Load default format when opening add modal
document.getElementById('addFeedModal').addEventListener('show.bs.modal', () => {
if (!document.getElementById('feedId').value) {
// Only load default if it's a new feed (not editing)
this.loadDefaultFormat();
}
});
// Reset form when modal is hidden
document.getElementById('addFeedModal').addEventListener('hidden.bs.modal', () => {
this.resetForm();
});
}
async editFeed(feedId) {
try {
const response = await fetch(`/api/feeds/${feedId}`);
const feed = await response.json();
// Populate form with feed data
document.getElementById('feedId').value = feed.id;
document.getElementById('feedModalTitle').textContent = 'Edit Feed Subscription';
document.getElementById('feedType').value = feed.feed_type;
document.getElementById('feedUrl').value = feed.feed_url;
document.getElementById('feedUrl').disabled = true; // Don't allow changing URL
document.getElementById('channelSelect').value = feed.channel_name;
document.getElementById('feedName').value = feed.feed_name || '';
document.getElementById('checkInterval').value = feed.check_interval_seconds || 300;
// Load default format if feed doesn't have a custom one
if (feed.output_format) {
document.getElementById('outputFormat').value = feed.output_format;
} else {
await this.loadDefaultFormat();
}
document.getElementById('messageSendInterval').value = feed.message_send_interval_seconds || 2.0;
if (feed.feed_type === 'api') {
document.getElementById('apiConfigGroup').style.display = 'block';
document.getElementById('apiConfig').value = feed.api_config ? JSON.stringify(JSON.parse(feed.api_config), null, 2) : '';
// Expand API config accordion when editing API feed
const apiCollapseEl = document.getElementById('apiConfigCollapse');
if (apiCollapseEl) {
const apiCollapse = new bootstrap.Collapse(apiCollapseEl, {show: true});
}
} else {
document.getElementById('apiConfigGroup').style.display = 'none';
}
// Load filter config if present
if (feed.filter_config) {
try {
document.getElementById('filterConfig').value = JSON.stringify(JSON.parse(feed.filter_config), null, 2);
} catch (e) {
document.getElementById('filterConfig').value = feed.filter_config;
}
} else {
document.getElementById('filterConfig').value = '';
}
// Load sort config if present
if (feed.sort_config) {
try {
document.getElementById('sortConfig').value = JSON.stringify(JSON.parse(feed.sort_config), null, 2);
} catch (e) {
document.getElementById('sortConfig').value = feed.sort_config;
}
} else {
document.getElementById('sortConfig').value = '';
}
const modal = new bootstrap.Modal(document.getElementById('addFeedModal'));
modal.show();
} catch (error) {
this.showError('Error loading feed: ' + error.message);
}
}
async saveFeed() {
const form = document.getElementById('addFeedForm');
const formData = new FormData(form);
const feedId = document.getElementById('feedId').value;
const isEdit = !!feedId;
const data = {
feed_type: formData.get('feed_type'),
feed_url: formData.get('feed_url'),
channel_name: formData.get('channel_name') || document.getElementById('newChannelName').value,
feed_name: formData.get('feed_name') || null,
check_interval_seconds: parseInt(formData.get('check_interval_seconds')) || 300,
output_format: formData.get('output_format') || null,
message_send_interval_seconds: parseFloat(formData.get('message_send_interval_seconds')) || 2.0
};
if (data.feed_type === 'api') {
const apiConfigText = document.getElementById('apiConfig').value;
if (apiConfigText) {
try {
data.api_config = JSON.parse(apiConfigText);
} catch (e) {
this.showError('Invalid API config JSON');
return;
}
}
}
// Parse filter config if provided
const filterConfigText = document.getElementById('filterConfig').value;
if (filterConfigText && filterConfigText.trim()) {
try {
data.filter_config = JSON.parse(filterConfigText);
} catch (e) {
this.showError('Invalid filter config JSON: ' + e.message);
return;
}
} else {
data.filter_config = null;
}
// Parse sort config if provided
const sortConfigText = document.getElementById('sortConfig').value;
if (sortConfigText && sortConfigText.trim()) {
try {
data.sort_config = JSON.parse(sortConfigText);
} catch (e) {
this.showError('Invalid sort config JSON: ' + e.message);
return;
}
} else {
data.sort_config = null;
}
try {
const url = isEdit ? `/api/feeds/${feedId}` : '/api/feeds';
const method = isEdit ? 'PUT' : 'POST';
const response = await fetch(url, {
method: method,
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(data)
});
const result = await response.json();
if (response.ok) {
this.showSuccess(`Feed subscription ${isEdit ? 'updated' : 'created'}`);
bootstrap.Modal.getInstance(document.getElementById('addFeedModal')).hide();
this.resetForm();
await this.loadFeeds();
await this.loadStatistics();
} else {
this.showError(result.error || `Failed to ${isEdit ? 'update' : 'create'} feed`);
}
} catch (error) {
this.showError(`Error ${isEdit ? 'updating' : 'creating'} feed: ` + error.message);
}
}
async loadDefaultFormat() {
try {
const response = await fetch('/api/feeds/default-format');
const data = await response.json();
document.getElementById('outputFormat').value = data.default_format || '{emoji} {body|truncate:100} - {date}\n{link|truncate:50}';
} catch (error) {
console.error('Error loading default format:', error);
// Fallback to hardcoded default
document.getElementById('outputFormat').value = '{emoji} {body|truncate:100} - {date}\n{link|truncate:50}';
}
}
async previewFormat() {
const feedUrl = document.getElementById('feedUrl').value;
const feedType = document.getElementById('feedType').value;
const outputFormat = document.getElementById('outputFormat').value;
const apiConfigText = document.getElementById('apiConfig').value;
const filterConfigText = document.getElementById('filterConfig').value;
const sortConfigText = document.getElementById('sortConfig').value;
if (!feedUrl) {
this.showError('Please enter a feed URL first');
return;
}
if (!outputFormat) {
this.showError('Please enter an output format');
return;
}
const previewResults = document.getElementById('previewResults');
const previewContent = document.getElementById('previewContent');
previewResults.style.display = 'block';
previewContent.innerHTML = '<div class="spinner-border spinner-border-sm" role="status"><span class="visually-hidden">Loading...</span></div>';
try {
const data = {
feed_url: feedUrl,
feed_type: feedType,
output_format: outputFormat
};
if (feedType === 'api' && apiConfigText) {
try {
data.api_config = JSON.parse(apiConfigText);
} catch (e) {
this.showError('Invalid API config JSON');
previewResults.style.display = 'none';
return;
}
}
if (filterConfigText && filterConfigText.trim()) {
try {
data.filter_config = JSON.parse(filterConfigText);
} catch (e) {
this.showError('Invalid filter config JSON: ' + e.message);
previewResults.style.display = 'none';
return;
}
}
if (sortConfigText && sortConfigText.trim()) {
try {
data.sort_config = JSON.parse(sortConfigText);
} catch (e) {
this.showError('Invalid sort config JSON: ' + e.message);
previewResults.style.display = 'none';
return;
}
}
const response = await fetch('/api/feeds/preview', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(data)
});
const result = await response.json();
if (response.ok && result.items) {
if (result.items.length === 0) {
previewContent.innerHTML = '<p class="text-muted small mb-0">No items found in feed</p>';
} else {
previewContent.innerHTML = result.items.map((item, index) => `
<div class="mb-2 p-2 border rounded">
<small class="text-muted d-block mb-1"><strong>Item ${index + 1}:</strong></small>
<pre class="mb-1 bg-light p-2 rounded small" style="white-space: pre-wrap; word-wrap: break-word; font-size: 0.8rem;">${item.formatted}</pre>
</div>
`).join('');
}
} else {
previewContent.innerHTML = `<p class="text-danger small mb-0">Error: ${result.error || 'Failed to preview feed'}</p>`;
}
} catch (error) {
previewContent.innerHTML = `<p class="text-danger">Error: ${error.message}</p>`;
}
}
resetForm() {
const form = document.getElementById('addFeedForm');
form.reset();
document.getElementById('feedId').value = '';
document.getElementById('feedModalTitle').textContent = 'Add Feed Subscription';
document.getElementById('feedUrl').disabled = false;
document.getElementById('apiConfigGroup').style.display = 'none';
document.getElementById('newChannelGroup').style.display = 'none';
document.getElementById('previewResults').style.display = 'none';
document.getElementById('filterConfig').value = '';
document.getElementById('sortConfig').value = '';
}
async viewFeed(feedId) {
try {
const response = await fetch(`/api/feeds/${feedId}`);
const feed = await response.json();
const content = `
<div class="row">
<div class="col-md-6">
<h6>Configuration</h6>
<p><strong>Name:</strong> ${feed.feed_name || 'N/A'}</p>
<p><strong>Type:</strong> ${feed.feed_type.toUpperCase()}</p>
<p><strong>URL:</strong> ${feed.feed_url}</p>
<p><strong>Channel:</strong> ${feed.channel_name}</p>
<p><strong>Check Interval:</strong> ${feed.check_interval_seconds}s</p>
<p><strong>Send Interval:</strong> ${feed.message_send_interval_seconds || 2.0}s</p>
<p><strong>Status:</strong> ${feed.enabled ? 'Enabled' : 'Disabled'}</p>
${feed.output_format ? `<p><strong>Output Format:</strong><br><code class="small">${feed.output_format.replace(/\n/g, '<br>')}</code></p>` : ''}
</div>
<div class="col-md-6">
<h6>Activity</h6>
<p><strong>Last Check:</strong> ${(() => {
if (!feed.last_check_time) return 'Never';
try {
let dateStr = feed.last_check_time;
if (/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/.test(dateStr)) {
dateStr = dateStr.replace(' ', 'T') + 'Z';
}
const date = new Date(dateStr);
return !isNaN(date.getTime()) ? date.toLocaleString() : feed.last_check_time;
} catch (e) {
return feed.last_check_time;
}
})()}</p>
<p><strong>Last Item:</strong> ${feed.last_item_id ? feed.last_item_id.substring(0, 30) + '...' : 'None'}</p>
<p><strong>Total Items:</strong> ${feed.activity?.length || 0}</p>
<p><strong>Errors:</strong> ${feed.errors?.length || 0}</p>
</div>
</div>
`;
document.getElementById('feedDetailsContent').innerHTML = content;
const modal = new bootstrap.Modal(document.getElementById('feedDetailsModal'));
modal.show();
} catch (error) {
this.showError('Error loading feed details: ' + error.message);
}
}
async toggleFeed(feedId, enabled) {
try {
const response = await fetch(`/api/feeds/${feedId}`, {
method: 'PUT',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({enabled: enabled})
});
if (response.ok) {
this.showSuccess(`Feed ${enabled ? 'enabled' : 'disabled'}`);
await this.loadFeeds();
} else {
const result = await response.json();
this.showError(result.error || 'Failed to update feed');
}
} catch (error) {
this.showError('Error updating feed: ' + error.message);
}
}
async deleteFeed(feedId) {
if (!confirm('Are you sure you want to delete this feed subscription?')) {
return;
}
try {
const response = await fetch(`/api/feeds/${feedId}`, {
method: 'DELETE'
});
if (response.ok) {
this.showSuccess('Feed subscription deleted');
await this.loadFeeds();
await this.loadStatistics();
} else {
const result = await response.json();
this.showError(result.error || 'Failed to delete feed');
}
} catch (error) {
this.showError('Error deleting feed: ' + error.message);
}
}
showError(message) {
const alert = document.createElement('div');
alert.className = 'alert alert-danger alert-dismissible fade show position-fixed';
alert.style.cssText = 'top: 20px; right: 20px; z-index: 9999; max-width: 300px;';
alert.innerHTML = `${message}<button type="button" class="btn-close" data-bs-dismiss="alert"></button>`;
document.body.appendChild(alert);
setTimeout(() => alert.remove(), 5000);
}
showSuccess(message) {
const alert = document.createElement('div');
alert.className = 'alert alert-success alert-dismissible fade show position-fixed';
alert.style.cssText = 'top: 20px; right: 20px; z-index: 9999; max-width: 300px;';
alert.innerHTML = `${message}<button type="button" class="btn-close" data-bs-dismiss="alert"></button>`;
document.body.appendChild(alert);
setTimeout(() => alert.remove(), 3000);
}
}
// Initialize feed manager when page loads
let feedManager;
document.addEventListener('DOMContentLoaded', () => {
feedManager = new FeedManager();
});
</script>
{% endblock %}
+718
View File
@@ -0,0 +1,718 @@
{% extends "base.html" %}
{% block title %}Radio Settings - MeshCore Bot{% endblock %}
{% block content %}
<div class="container-fluid">
<div class="row">
<div class="col-12">
<h1 class="mb-4">
<i class="fas fa-broadcast-tower"></i> Radio Settings
</h1>
<!-- Statistics Cards -->
<div class="row mb-4">
<div class="col-md-3">
<div class="card">
<div class="card-body">
<h5 class="card-title">Total Channels</h5>
<h2 id="total-channels">-</h2>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card">
<div class="card-body">
<h5 class="card-title">Available Slots</h5>
<h2 id="available-slots">-</h2>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card">
<div class="card-body">
<h5 class="card-title">Channels with Feeds</h5>
<h2 id="channels-with-feeds">-</h2>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card">
<div class="card-body">
<h5 class="card-title">Empty Channels</h5>
<h2 id="empty-channels">-</h2>
</div>
</div>
</div>
</div>
<!-- Channel Management -->
<div class="card">
<div class="card-header d-flex justify-content-between align-items-center">
<h5 class="mb-0">Channel Management</h5>
<button class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#createChannelModal">
<i class="fas fa-plus"></i> Create Channel
</button>
</div>
<div class="card-body">
<div class="table-responsive">
<table class="table table-striped table-hover" id="channelsTable">
<thead>
<tr>
<th>Index</th>
<th>Name</th>
<th>Type</th>
<th>Feed Count</th>
<th>Status</th>
<th>Actions</th>
</tr>
</thead>
<tbody id="channelsTableBody">
<tr>
<td colspan="6" class="text-center">Loading...</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Create Channel Modal -->
<div class="modal fade" id="createChannelModal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Create Channel</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<form id="createChannelForm">
<div class="mb-3">
<label class="form-label">Channel Name</label>
<input type="text" class="form-control" id="channelName" name="channel_name" required placeholder="#channelname or channelname">
<small class="form-text text-muted">
<strong>Hashtag channel:</strong> Start with # (e.g., #emergency) - key auto-generated<br>
<strong>Custom channel:</strong> No # prefix - you must provide a channel key
</small>
</div>
<div class="mb-3" id="channelKeyGroup" style="display: none;">
<label class="form-label">Channel Key (32 hex characters) <span class="text-danger">*</span></label>
<input type="text" class="form-control" id="channelKey" name="channel_key" placeholder="00000000000000000000000000000000" pattern="[0-9a-fA-F]{32}" maxlength="32">
<small class="form-text text-muted">Required for custom channels. Must be exactly 32 hexadecimal characters (16 bytes).</small>
</div>
<div class="mb-3">
<div class="alert alert-info">
<strong>Channel Index:</strong> <span id="autoChannelIndex">-</span>
<br><small>The lowest available channel index will be automatically selected</small>
</div>
</div>
</form>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-primary" id="saveChannelBtn">Create Channel</button>
</div>
</div>
</div>
</div>
<!-- Channel Details Modal -->
<div class="modal fade" id="channelDetailsModal" tabindex="-1">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Channel Details</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body" id="channelDetailsContent">
Loading...
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
</div>
</div>
</div>
</div>
{% endblock %}
{% block extra_js %}
<script>
class RadioManager {
constructor() {
this.channels = [];
this.initialize();
}
async initialize() {
await this.loadChannels();
await this.loadStatistics();
this.setupEventHandlers();
// Auto-refresh every 30 seconds
setInterval(() => this.loadChannels(), 30000);
setInterval(() => this.loadStatistics(), 60000);
}
async loadChannels() {
try {
const response = await fetch('/api/channels');
const data = await response.json();
this.channels = data.channels || [];
this.renderChannels();
this.updateChannelIndexDisplay();
} catch (error) {
console.error('Error loading channels:', error);
this.showError('Failed to load channels: ' + error.message);
}
}
async loadStatistics() {
try {
const response = await fetch('/api/channels/stats');
const data = await response.json();
// Calculate statistics
const totalChannels = this.channels.length;
const availableSlots = 40 - totalChannels; // Max 40 channels
const channelsWithFeeds = Object.keys(data.channel_feed_counts || {}).length;
const emptyChannels = totalChannels - channelsWithFeeds;
document.getElementById('total-channels').textContent = totalChannels;
document.getElementById('available-slots').textContent = availableSlots;
document.getElementById('channels-with-feeds').textContent = channelsWithFeeds;
document.getElementById('empty-channels').textContent = emptyChannels;
} catch (error) {
console.error('Error loading statistics:', error);
}
}
getLowestAvailableChannelIndex() {
const usedIndices = new Set(this.channels.map(c => c.channel_idx || c.index));
// Find the lowest available index (0-39)
for (let i = 0; i < 40; i++) {
if (!usedIndices.has(i)) {
return i;
}
}
// All channels are used
return null;
}
updateChannelIndexDisplay() {
const display = document.getElementById('autoChannelIndex');
const lowestIndex = this.getLowestAvailableChannelIndex();
if (lowestIndex !== null) {
display.textContent = `Channel ${lowestIndex}`;
display.className = 'text-success fw-bold';
} else {
display.textContent = 'No available slots (all 40 channels in use)';
display.className = 'text-danger fw-bold';
}
}
renderChannels() {
const tbody = document.getElementById('channelsTableBody');
if (this.channels.length === 0) {
tbody.innerHTML = '<tr><td colspan="6" class="text-center">No channels configured</td></tr>';
return;
}
// Get feed counts per channel
fetch('/api/channels/stats').then(r => r.json()).then(stats => {
const feedCounts = stats.channel_feed_counts || {};
tbody.innerHTML = this.channels.map(channel => {
const channelName = channel.name || channel.channel_name || `Channel ${channel.channel_idx || channel.index}`;
const feedCount = feedCounts[channelName] || 0;
const typeBadge = channel.type === 'hashtag'
? '<span class="badge bg-primary">Hashtag</span>'
: '<span class="badge bg-secondary">Custom</span>';
return `
<tr>
<td>${channel.channel_idx || channel.index}</td>
<td>${channelName}</td>
<td>${typeBadge}</td>
<td>${feedCount > 0 ? `<a href="/feeds?channel=${encodeURIComponent(channelName)}">${feedCount}</a>` : '0'}</td>
<td><span class="badge bg-success">Active</span></td>
<td>
<button class="btn btn-sm btn-info" onclick="radioManager.viewChannel(${channel.channel_idx || channel.index})" title="View Details">
<i class="fas fa-eye"></i>
</button>
<button class="btn btn-sm btn-danger" onclick="radioManager.deleteChannel(${channel.channel_idx || channel.index})" title="Delete" ${feedCount > 0 ? 'disabled' : ''}>
<i class="fas fa-trash"></i>
</button>
</td>
</tr>
`;
}).join('');
});
}
setupEventHandlers() {
// Channel name input - update key preview and toggle key input
const channelNameInput = document.getElementById('channelName');
if (channelNameInput) {
channelNameInput.addEventListener('input', (e) => {
this.handleChannelNameChange(e.target.value).catch(err => {
console.error('Error in handleChannelNameChange:', err);
});
});
}
// Save channel
const saveBtn = document.getElementById('saveChannelBtn');
if (saveBtn) {
saveBtn.addEventListener('click', () => this.saveChannel());
}
}
handleChannelNameChange(channelName) {
const isHashtag = channelName.trim().startsWith('#');
const keyGroup = document.getElementById('channelKeyGroup');
const keyInput = document.getElementById('channelKey');
if (isHashtag) {
// Hashtag channel - hide key input
keyGroup.style.display = 'none';
keyInput.removeAttribute('required');
} else {
// Custom channel - show key input
keyGroup.style.display = 'block';
keyInput.setAttribute('required', 'required');
}
}
async saveChannel() {
const form = document.getElementById('createChannelForm');
const formData = new FormData(form);
const saveBtn = document.getElementById('saveChannelBtn');
const originalBtnText = saveBtn.innerHTML;
const channelName = formData.get('channel_name')?.trim();
const channelKey = formData.get('channel_key')?.trim();
if (!channelName) {
this.showError('Channel name is required');
return;
}
// Get the lowest available channel index
const channelIdx = this.getLowestAvailableChannelIndex();
if (channelIdx === null) {
this.showError('No available channel slots. All 40 channels are in use.');
return;
}
const isHashtag = channelName.startsWith('#');
// Validate custom channel has key
if (!isHashtag && !channelKey) {
this.showError('Channel key is required for custom channels (channels without # prefix)');
return;
}
// Validate key format if provided
if (channelKey) {
if (channelKey.length !== 32) {
this.showError('Channel key must be exactly 32 hexadecimal characters');
return;
}
if (!/^[0-9a-fA-F]{32}$/.test(channelKey)) {
this.showError('Channel key must contain only hexadecimal characters (0-9, a-f, A-F)');
return;
}
}
const data = {
name: channelName,
channel_idx: channelIdx
};
// Add key only for custom channels
if (!isHashtag && channelKey) {
data.channel_key = channelKey;
}
// Disable button and show loading state
saveBtn.disabled = true;
saveBtn.innerHTML = '<span class="spinner-border spinner-border-sm me-2" role="status"></span>Creating...';
try {
const response = await fetch('/api/channels', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(data)
});
const result = await response.json();
if (response.ok) {
if (result.pending && result.operation_id) {
// Operation is queued, poll for status
saveBtn.innerHTML = '<span class="spinner-border spinner-border-sm me-2" role="status"></span>Processing...';
await this.pollOperationStatus(result.operation_id, saveBtn, originalBtnText);
} else {
// Immediate success
this.showSuccess('Channel created successfully');
bootstrap.Modal.getInstance(document.getElementById('createChannelModal')).hide();
form.reset();
this.handleChannelNameChange('');
await this.loadChannels();
await this.loadStatistics();
saveBtn.disabled = false;
saveBtn.innerHTML = originalBtnText;
}
} else {
this.showError(result.error || 'Failed to create channel');
saveBtn.disabled = false;
saveBtn.innerHTML = originalBtnText;
}
} catch (error) {
this.showError('Error creating channel: ' + error.message);
saveBtn.disabled = false;
saveBtn.innerHTML = originalBtnText;
}
}
async pollOperationStatus(operationId, button, originalButtonText, maxWait = 60) {
const startTime = Date.now();
const checkInterval = 1000; // Check every second
let attempts = 0;
const maxAttempts = Math.floor(maxWait * 1000 / checkInterval);
while (attempts < maxAttempts) {
await new Promise(resolve => setTimeout(resolve, checkInterval));
attempts++;
try {
const response = await fetch(`/api/channel-operations/${operationId}`);
const result = await response.json();
if (result.status === 'completed') {
this.showSuccess('Channel created successfully');
bootstrap.Modal.getInstance(document.getElementById('createChannelModal')).hide();
document.getElementById('createChannelForm').reset();
this.handleChannelNameChange('');
await this.loadChannels();
await this.loadStatistics();
button.disabled = false;
button.innerHTML = originalButtonText;
return;
} else if (result.status === 'failed') {
this.showError(result.error_message || 'Channel operation failed');
button.disabled = false;
button.innerHTML = originalButtonText;
return;
}
// If still pending, continue polling
// Update button text with elapsed time
const elapsed = Math.floor((Date.now() - startTime) / 1000);
button.innerHTML = `<span class="spinner-border spinner-border-sm me-2" role="status"></span>Processing... (${elapsed}s)`;
} catch (error) {
console.error('Error polling operation status:', error);
// Continue polling on error
}
}
// Timeout - but continue polling in background and check if it completed
// Give it one more check after timeout message
await new Promise(resolve => setTimeout(resolve, checkInterval));
try {
const response = await fetch(`/api/channel-operations/${operationId}`);
const result = await response.json();
if (result.status === 'completed') {
// It completed! Show success and close dialog
this.showSuccess('Channel created successfully');
bootstrap.Modal.getInstance(document.getElementById('createChannelModal')).hide();
document.getElementById('createChannelForm').reset();
this.handleChannelNameChange('');
await this.loadChannels();
await this.loadStatistics();
button.disabled = false;
button.innerHTML = originalButtonText;
return;
} else if (result.status === 'failed') {
this.showError(result.error_message || 'Channel operation failed');
button.disabled = false;
button.innerHTML = originalButtonText;
return;
}
} catch (error) {
console.error('Error checking final status:', error);
}
// Still pending after timeout - show info message but keep dialog open
// and continue checking in background
this.showError('Channel operation is taking longer than expected. The dialog will close automatically when complete.');
button.innerHTML = '<span class="spinner-border spinner-border-sm me-2" role="status"></span>Still processing...';
// Continue polling in background (extend timeout to 120 seconds total)
const extendedMaxWait = 120;
const extendedMaxAttempts = Math.floor(extendedMaxWait * 1000 / checkInterval);
while (attempts < extendedMaxAttempts) {
await new Promise(resolve => setTimeout(resolve, checkInterval));
attempts++;
try {
const response = await fetch(`/api/channel-operations/${operationId}`);
const result = await response.json();
if (result.status === 'completed') {
this.showSuccess('Channel created successfully');
bootstrap.Modal.getInstance(document.getElementById('createChannelModal')).hide();
document.getElementById('createChannelForm').reset();
this.handleChannelNameChange('');
await this.loadChannels();
await this.loadStatistics();
button.disabled = false;
button.innerHTML = originalButtonText;
return;
} else if (result.status === 'failed') {
this.showError(result.error_message || 'Channel operation failed');
button.disabled = false;
button.innerHTML = originalButtonText;
return;
}
// Update button text with elapsed time
const elapsed = Math.floor((Date.now() - startTime) / 1000);
button.innerHTML = `<span class="spinner-border spinner-border-sm me-2" role="status"></span>Still processing... (${elapsed}s)`;
} catch (error) {
console.error('Error polling operation status:', error);
// Continue polling on error
}
}
// Final timeout - really give up now
this.showError('Channel operation timed out. Please check the channel list to see if it was created.');
button.disabled = false;
button.innerHTML = originalButtonText;
await this.loadChannels();
await this.loadStatistics();
}
async viewChannel(channelIdx) {
try {
// Get channel feeds
const feedsResponse = await fetch(`/api/channels/${channelIdx}/feeds`);
const feedsData = await feedsResponse.json();
const channel = this.channels.find(c => (c.channel_idx || c.index) === channelIdx);
if (!channel) {
this.showError('Channel not found');
return;
}
const channelKey = channel.key_hex || '';
const keyDisplay = channelKey
? `<code style="font-size: 0.9em; word-break: break-all;">${channelKey}</code> <button class="btn btn-sm btn-outline-secondary ms-2" onclick="navigator.clipboard.writeText('${channelKey}'); radioManager.showSuccess('Key copied to clipboard');"><i class="fas fa-copy"></i> Copy</button>`
: '<em>No key available</em>';
const content = `
<div class="row">
<div class="col-md-6">
<h6>Channel Information</h6>
<p><strong>Name:</strong> ${channel.name || channel.channel_name}</p>
<p><strong>Index:</strong> ${channel.channel_idx || channel.index}</p>
<p><strong>Type:</strong> ${channel.type || 'Unknown'}</p>
<p><strong>Key:</strong> ${keyDisplay}</p>
</div>
<div class="col-md-6">
<h6>Feed Subscriptions</h6>
${feedsData.feeds && feedsData.feeds.length > 0
? `<ul>${feedsData.feeds.map(f => `<li><a href="/feeds?id=${f.id}">${f.feed_name || f.feed_url}</a></li>`).join('')}</ul>`
: '<p>No feed subscriptions</p>'}
</div>
</div>
`;
document.getElementById('channelDetailsContent').innerHTML = content;
const modal = new bootstrap.Modal(document.getElementById('channelDetailsModal'));
modal.show();
} catch (error) {
this.showError('Error loading channel details: ' + error.message);
}
}
async deleteChannel(channelIdx) {
const channel = this.channels.find(c => (c.channel_idx || c.index) === channelIdx);
if (!channel) {
this.showError('Channel not found');
return;
}
// Check if channel has feeds
try {
const feedsResponse = await fetch(`/api/channels/${channelIdx}/feeds`);
const feedsData = await feedsResponse.json();
if (feedsData.feeds && feedsData.feeds.length > 0) {
if (!confirm(`This channel has ${feedsData.feeds.length} active feed subscription(s). Are you sure you want to delete it?`)) {
return;
}
} else {
if (!confirm('Are you sure you want to delete this channel?')) {
return;
}
}
} catch (error) {
// Continue with deletion even if feed check fails
}
// Find the delete button and show loading state
const deleteButtons = document.querySelectorAll(`button[onclick*="deleteChannel(${channelIdx})"]`);
let deleteBtn = null;
if (deleteButtons.length > 0) {
deleteBtn = deleteButtons[0];
const originalHtml = deleteBtn.innerHTML;
deleteBtn.disabled = true;
deleteBtn.innerHTML = '<span class="spinner-border spinner-border-sm" role="status"></span>';
try {
const response = await fetch(`/api/channels/${channelIdx}`, {
method: 'DELETE'
});
const result = await response.json();
if (response.ok) {
if (result.pending && result.operation_id) {
// Operation is queued, poll for status
await this.pollDeleteOperationStatus(result.operation_id, deleteBtn, originalHtml);
} else {
// Immediate success
this.showSuccess('Channel deleted successfully');
await this.loadChannels();
await this.loadStatistics();
deleteBtn.disabled = false;
deleteBtn.innerHTML = originalHtml;
}
} else {
this.showError(result.error || 'Failed to delete channel');
deleteBtn.disabled = false;
deleteBtn.innerHTML = originalHtml;
}
} catch (error) {
this.showError('Error deleting channel: ' + error.message);
if (deleteBtn) {
deleteBtn.disabled = false;
deleteBtn.innerHTML = originalHtml;
}
}
} else {
// Fallback if button not found
try {
const response = await fetch(`/api/channels/${channelIdx}`, {
method: 'DELETE'
});
const result = await response.json();
if (response.ok) {
if (result.pending && result.operation_id) {
await this.pollDeleteOperationStatus(result.operation_id);
} else {
this.showSuccess('Channel deleted successfully');
await this.loadChannels();
await this.loadStatistics();
}
} else {
this.showError(result.error || 'Failed to delete channel');
}
} catch (error) {
this.showError('Error deleting channel: ' + error.message);
}
}
}
async pollDeleteOperationStatus(operationId, button = null, originalButtonHtml = null, maxWait = 60) {
const startTime = Date.now();
const checkInterval = 1000; // Check every second
let attempts = 0;
const maxAttempts = Math.floor(maxWait * 1000 / checkInterval);
while (attempts < maxAttempts) {
await new Promise(resolve => setTimeout(resolve, checkInterval));
attempts++;
try {
const response = await fetch(`/api/channel-operations/${operationId}`);
const result = await response.json();
if (result.status === 'completed') {
this.showSuccess('Channel deleted successfully');
await this.loadChannels();
await this.loadStatistics();
if (button) {
button.disabled = false;
button.innerHTML = originalButtonHtml;
}
return;
} else if (result.status === 'failed') {
this.showError(result.error_message || 'Channel deletion failed');
if (button) {
button.disabled = false;
button.innerHTML = originalButtonHtml;
}
return;
}
// If still pending, continue polling
} catch (error) {
console.error('Error polling operation status:', error);
// Continue polling on error
}
}
// Timeout
this.showError('Channel deletion is taking longer than expected. Please check the channel list to see if it was deleted.');
if (button) {
button.disabled = false;
button.innerHTML = originalButtonHtml;
}
// Still refresh the channel list in case it was deleted
await this.loadChannels();
await this.loadStatistics();
}
showError(message) {
const alert = document.createElement('div');
alert.className = 'alert alert-danger alert-dismissible fade show position-fixed';
alert.style.cssText = 'top: 20px; right: 20px; z-index: 9999; max-width: 300px;';
alert.innerHTML = `${message}<button type="button" class="btn-close" data-bs-dismiss="alert"></button>`;
document.body.appendChild(alert);
setTimeout(() => alert.remove(), 5000);
}
showSuccess(message) {
const alert = document.createElement('div');
alert.className = 'alert alert-success alert-dismissible fade show position-fixed';
alert.style.cssText = 'top: 20px; right: 20px; z-index: 9999; max-width: 300px;';
alert.innerHTML = `${message}<button type="button" class="btn-close" data-bs-dismiss="alert"></button>`;
document.body.appendChild(alert);
setTimeout(() => alert.remove(), 3000);
}
}
// Initialize radio manager when page loads
let radioManager;
document.addEventListener('DOMContentLoaded', () => {
radioManager = new RadioManager();
});
</script>
{% endblock %}
+1
View File
@@ -18,3 +18,4 @@ retry-requests>=1.0.0
flask>=2.3.0
flask-socketio>=5.3.0
meshcore-cli
feedparser>=6.0.10