mirror of
https://github.com/agessaman/meshcore-bot.git
synced 2026-08-27 21:10:13 +00:00
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:
+147
-68
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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 -->
|
||||
|
||||
@@ -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"> </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 %}
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -18,3 +18,4 @@ retry-requests>=1.0.0
|
||||
flask>=2.3.0
|
||||
flask-socketio>=5.3.0
|
||||
meshcore-cli
|
||||
feedparser>=6.0.10
|
||||
|
||||
Reference in New Issue
Block a user