mirror of
https://github.com/agessaman/meshcore-bot.git
synced 2026-08-14 14:40:00 +00:00
Remaining findings from the same triage pass. None are security
relevant; each produces a wrong user-visible result.
- greeter: treat rollout_started_at as UTC. It is written by SQLite
CURRENT_TIMESTAMP, but datetime.timestamp() read the naive value as
local time, shifting the backfill cutoff by the host's offset (7h on
PT). Users who posted inside that window were never marked as already
greeted and could be sent a welcome they should not have received.
- greeter: write greeted_at in SQLite's own format. The rollout backfill
used isoformat() while every other path used CURRENT_TIMESTAMP. "T"
sorts above a space, so ORDER BY greeted_at interleaved the two
formats wrongly, corrupting duplicate cleanup and the web viewer's
recently-greeted list.
- greeter: let an empty `channels =` disable the command. BaseCommand
reads an empty value as disabled-on-channels, but the greeter
collapsed "key absent" and "key present but empty" into one fallback
and kept greeting via monitor_channels.
- greeter: stop comma-splitting greeting text. channel_greetings split
entries on ",", so "Public:Welcome to the mesh, {sender}!" was stored
as "Welcome to the mesh" with the placeholder silently dropped. A
fragment now starts a new entry only when the text before its first
colon looks like a channel name, which keeps commas, URLs and clock
times attached to the greeting they belong to.
- wxsim_parser: match condition abbreviations longest-first. Substring
matching in dict order let RAIN shadow CHNC. RAIN, so five conditions
lost their "chance" qualifier (rain, snow, drizzle, t-storm, and
FAIR-P.C.).
- wxsim_parser: re-anchor "now" on each parse. current_date and
current_year were fixed at construction and wx_command builds one
parser at startup, so after a few days forecast dates rolled back a
year and staleness checks read permanently true.
- thesportsdb_client: hold the rate-limit read/sleep/write under a lock.
Concurrent callers read the same last_request_time, slept the same
interval and fired together, bursting past the 2.1s throttle. This
exposes an unrelated request fan-out problem in fetch_league_scores;
filed in TODO.md rather than fixed here.
- alert_command: stop duplicating the first incident. The tail treated
any single-line buffer as "header only" and appended incidents[0],
but messages after the first carry no header, so a final chunk holding
one incident had incident 0 pasted onto it. The same confusion inside
the loop also let a second incident be appended past the 130-character
limit.
- feed: reject non-positive poll intervals. -1 is truthy and was stored,
and the poller's `now - last_check >= interval` then treats the feed
as permanently due and re-fetches the URL every cycle; 0 was ignored
while still reporting success. The web viewer, which is the primary
editor, had no validation at all, and a JSON null there raised a
TypeError that aborted the poll cycle for every feed rather than one.
feed_manager falls back to the default for rows written before this.
- transmission_tracker: age out confirmed transmissions that have
repeats. Cleanup removed only repeat_count == 0, so repeated records
accumulated for the lifetime of the process, which matters on a Pi
Zero. Repeat counts are already persisted to packet_stream, so the
longer 30-minute retention loses nothing.
- mesh_graph: weighted-merge avg_hop_position when promoting an edge.
Promotion overwrote the average with the single new observation, so an
edge averaging 2.0 over 4 observations became 9.0 instead of 3.4 and
skewed subsequent path scoring.
- multitest_command: show the path that ends exactly at the display LCP.
The suffix helpers are correct in isolation; the loss happens in the
cluster formatters, where _shrink_display_lcp refuses to shrink a
single-token LCP and the trunk then rendered as "96 ┐", meaning
"everything continues past here" and dropping the bare 96 route while
the header still counted it. Now uses the file's existing "├ common"
marker. Empty suffixes are also no longer handed to the nested
renderer, which mapped them onto the same [] as a route ending at the
inner LCP and drew a row for a route that did not exist.
- sports_mappings: stop shadowing seven unique team nicknames. In one
flat dict a repeated key silently drops the earlier team, so hawks
resolved to the NBA Hawks rather than the Seahawks alias it was added
as, blazers to Kamloops rather than Portland, and rockets to Kelowna
rather than Houston. First definition now wins for hawks, giants,
jets, rangers, kings, blazers and rockets; every shadowed team keeps
its unambiguous full-name alias. The 55 city and abbreviation
collisions (chicago, sf, la, ...) are genuinely ambiguous and stay
last-wins, now pinned by a test so a new collision fails loudly
instead of passing unnoticed.
447 lines
18 KiB
Python
447 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"
|
|
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
|
|
|
|
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',
|
|
)
|
|
|
|
def can_execute(self, message: MeshMessage, skip_channel_check: bool = False) -> bool:
|
|
"""Check if this command can be executed (enabled, admin only)"""
|
|
if not self.feed_enabled:
|
|
return False
|
|
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 = 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
|
|
|