mirror of
https://github.com/agessaman/meshcore-bot.git
synced 2026-10-31 01:52:38 +00:00
Fixes from a Codex review of the previous commit, plus the shared settings the rebase onto dev showed were still missing.
Settings that misstated or changed what runs:
- sports never read [Sports_Command] api_timeout (documented in the example config); the new field did nothing. It now sets the ESPN timeout.
- The path-byte requirement help on test, multitest and path said "bytes per hop"; the gate checks the path's total size (at least 2, or exactly 3).
- feed's allow_private_urls showed false while it inherited true from [Feed_Manager]. Fields can now name an inherit_section whose value they show when unset.
- notify_on_prefix_bytes was an int field for a value the service reads as a list (2,3), so the form refused a valid setting.
- The greeter's form defaults differed from the fallbacks it uses when the keys are missing. Both now come from shared constants.
- The MQTT weather limits accepted values the service clamps (payload 256 to 1 MiB, staleness 5 s to 7 days, length 64 to 4000).
Shared settings: the wx card now carries the [Weather] temperature, wind and precipitation units and the three temperature_*_format templates (with previews through format_temperature_high_low), next to wx's own [Wx_Command] unit overrides, which default to "Same as [Weather]". Two fields with one key in different sections are told apart as key@Section in the save payload; a bare key still resolves when only one field has it. An enum's "" option, like a blank number, removes the key. Packet capture brokers gain username, password, topic prefix, JWT timings and include_decoded; blank inheriting values are left out of block saves, since the service reads them with getint/getboolean when present.
Previews:
- They use the bot's saved config: a blank test format falls back to [Keywords] test, multitest lays out paths per condense_paths, and MQTT applies passthrough_max_length.
- A quoted empty template ("") counts as blank, as the commands strip the quotes first.
- A str.format width or precision over 500 isn't rendered, so `{sender:100000000}` can't make the endpoint build a huge string.
- The page invalidates an in-flight preview as soon as the field changes, and shows the server's note about fallbacks instead of guessing it.
The endpoint inventory golden gains the preview route (401 for anonymous requests when a password is set).
446 lines
18 KiB
Python
446 lines
18 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
Feed command for the MeshCore Bot
|
|
Handles RSS and API feed subscription management
|
|
"""
|
|
|
|
import json
|
|
from typing import Optional
|
|
|
|
from ..models import MeshMessage
|
|
from ..security_utils import sanitize_input, sanitize_name, validate_external_url
|
|
from .base_command import BaseCommand
|
|
|
|
|
|
class FeedCommand(BaseCommand):
|
|
"""Handles feed subscription management"""
|
|
|
|
# Plugin metadata
|
|
name = "feed"
|
|
honors_skip_channel_check = False
|
|
admin_only = True
|
|
enabled_attr = "feed_enabled"
|
|
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
|
|
requires_internet = True # Requires internet access for RSS/API feed fetching
|
|
|
|
# Web-viewer settings schema (see modules/settings_schema.py).
|
|
settings_schema = [
|
|
{"key": "allow_private_urls", "label": "Allow private URLs", "type": "bool", "default": False,
|
|
"inherit_section": "Feed_Manager",
|
|
"help": "Accept feed URLs on private or internal addresses. Off guards against SSRF. "
|
|
"Unset falls back to [Feed_Manager] allow_private_urls."},
|
|
]
|
|
|
|
def __init__(self, bot):
|
|
super().__init__(bot)
|
|
self.db_path = bot.db_manager.db_path
|
|
self.feed_enabled = self.get_config_value('Feed_Command', 'enabled', fallback=True, value_type='bool')
|
|
if bot.config.has_section('Feed_Manager'):
|
|
try:
|
|
feed_manager_allow_private = bot.config.getboolean(
|
|
'Feed_Manager',
|
|
'allow_private_urls',
|
|
fallback=False,
|
|
)
|
|
except ValueError:
|
|
feed_manager_allow_private = False
|
|
else:
|
|
feed_manager_allow_private = False
|
|
self.allow_private_urls = self.get_config_value(
|
|
'Feed_Command',
|
|
'allow_private_urls',
|
|
fallback=feed_manager_allow_private,
|
|
value_type='bool',
|
|
)
|
|
|
|
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, message: MeshMessage | None = None) -> 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 = sanitize_name(args[2], max_length=64)
|
|
feed_name = sanitize_input(args[3], max_length=100) if len(args) > 3 else None
|
|
api_config = args[4] if len(args) > 4 and feed_type == 'api' else None
|
|
|
|
# Validate URL — full SSRF protection (IP range + DNS check)
|
|
if not validate_external_url(feed_url, allow_private=self.allow_private_urls):
|
|
return await self.send_response(message, "Invalid or unsafe URL")
|
|
|
|
# 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)
|
|
|
|
max_len = self.get_max_message_length(message)
|
|
header = f"Feeds({len(feeds)}):"
|
|
chunks: list[str] = []
|
|
current = header
|
|
for feed in feeds:
|
|
status = "on" if feed['enabled'] else "off"
|
|
raw_name = feed.get('feed_name') or feed['feed_url']
|
|
name = raw_name if len(raw_name) <= 20 else raw_name[:17] + "..."
|
|
row = f"{feed['id']}:{name} {feed['feed_type']}->{feed['channel_name']} [{status}]"
|
|
candidate = f"{current}\n{row}"
|
|
if len(candidate.encode('utf-8')) <= max_len:
|
|
current = candidate
|
|
else:
|
|
chunks.append(current)
|
|
page = len(chunks) + 1
|
|
current = f"Feeds({len(feeds)}) p{page}:\n{row}"
|
|
chunks.append(current)
|
|
return await self.send_response_chunked(message, chunks)
|
|
|
|
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 = "on" if feed['enabled'] else "off"
|
|
last_check = str(feed.get('last_check_time') or "Never")
|
|
if len(last_check) > 16:
|
|
last_check = last_check[:16]
|
|
last_item = str(feed.get('last_item_id') or "None")
|
|
if last_item != "None" and len(last_item) > 20:
|
|
last_item = last_item[:17] + "..."
|
|
url = feed['feed_url']
|
|
if len(url) > 40:
|
|
url = url[:37] + "..."
|
|
interval = feed.get('check_interval_seconds', 300)
|
|
response = (
|
|
f"Feed#{feed_id} {status} {feed['feed_type']} "
|
|
f"ch={feed['channel_name']} int={interval}s\n"
|
|
f"{url}\n"
|
|
f"chk={last_check} last={last_item}"
|
|
)
|
|
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 validate_external_url(feed_url):
|
|
return await self.send_response(message, "Invalid or unsafe URL")
|
|
|
|
# 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
|
|
|
|
# A non-positive interval makes every poll cycle see the feed as due
|
|
# (current_time - last_check >= interval always holds), so the poller
|
|
# would hammer the URL forever.
|
|
if interval is not None and interval <= 0:
|
|
return await self.send_response(
|
|
message, "Interval must be a positive number of seconds"
|
|
)
|
|
|
|
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 _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"""
|
|
|
|
with self.bot.db_manager.connection() 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"""
|
|
|
|
with self.bot.db_manager.connection() 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"""
|
|
|
|
with self.bot.db_manager.connection() 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 self.bot.db_manager.connection() 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 self.bot.db_manager.connection() 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"""
|
|
|
|
with self.bot.db_manager.connection() 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"""
|
|
|
|
with self.bot.db_manager.connection() as conn:
|
|
cursor = conn.cursor()
|
|
|
|
# ``is not None``, not truthiness: 0 must not be mistaken for
|
|
# "no interval given" (callers reject it before reaching here).
|
|
if interval is not None:
|
|
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
|
|
|