diff --git a/docs/FEEDS.md b/docs/FEEDS.md
index 4b7c12f..be1dd17 100644
--- a/docs/FEEDS.md
+++ b/docs/FEEDS.md
@@ -264,6 +264,42 @@ Filter configuration determines which items are sent to channels.
- `not_matches` - Regex does not match
- `contains` - String contains value
- `not_contains` - String does not contain value
+- `within_days` - Item timestamp is within the last **N** calendar days (rolling window from **now**, UTC)
+- `within_weeks` - Same as `within_days` with **N** × 7 days (e.g. four weeks ≈ `within_weeks` `4` or `within_days` `28`)
+
+`within_days` / `within_weeks` require a `field` pointing at a date (same paths as sort: `published` for RSS, or `raw.SomeTimeField` for APIs). They also require `days` or `weeks` respectively.
+
+If the date is missing or cannot be parsed, the condition **fails** (item is excluded). Set `"include_if_missing": true` on that condition to treat missing/unparseable dates as a pass instead.
+
+**Examples:**
+
+```json
+{
+ "conditions": [
+ {
+ "field": "published",
+ "operator": "within_days",
+ "days": 28
+ }
+ ],
+ "logic": "AND"
+}
+```
+
+```json
+{
+ "conditions": [
+ {
+ "field": "raw.LastUpdatedTime",
+ "operator": "within_weeks",
+ "weeks": 4
+ }
+ ],
+ "logic": "AND"
+}
+```
+
+Date parsing for filters matches sorting: ISO strings, Microsoft `/Date(...)/`, Unix timestamps (seconds or milliseconds), and common string formats.
### Logic
@@ -277,6 +313,8 @@ For API feeds, use `raw.field` or `raw.nested.field` to access API response fiel
- `raw.EventStatus`
- `raw.StartRoadwayLocation.RoadName`
+For RSS feeds, use `published` for the item publication time in `within_days` / `within_weeks` conditions.
+
## Sort Configuration
Sort items before processing:
diff --git a/modules/feed_filter_eval.py b/modules/feed_filter_eval.py
new file mode 100644
index 0000000..762da12
--- /dev/null
+++ b/modules/feed_filter_eval.py
@@ -0,0 +1,274 @@
+"""
+Shared feed filter_config evaluation (RSS/API items).
+
+Used by FeedManager and the web viewer preview so filter behavior stays consistent.
+"""
+
+from __future__ import annotations
+
+import json
+import re
+from datetime import datetime, timedelta, timezone
+from typing import Any, Callable, Dict, Optional, Union
+
+
+def get_nested_value(data: Any, path: str, default: Any = "") -> Any:
+ """Nested dict/list access using dot notation (same rules as FeedManager._get_nested_value)."""
+ if not path or not data:
+ return default
+
+ parts = path.split(".")
+ value = data
+
+ for part in parts:
+ if isinstance(value, dict):
+ value = value.get(part)
+ elif isinstance(value, list):
+ try:
+ idx = int(part)
+ if 0 <= idx < len(value):
+ value = value[idx]
+ else:
+ return default
+ except (ValueError, TypeError):
+ return default
+ else:
+ return default
+
+ if value is None:
+ return default
+
+ return value if value is not None else default
+
+
+def parse_microsoft_date(date_str: str) -> Optional[datetime]:
+ """Parse Microsoft JSON date format: /Date(timestamp-offset)/"""
+ if not date_str or not isinstance(date_str, str):
+ return None
+
+ match = re.match(r"/Date\((\d+)([+-]\d+)?\)/", date_str)
+ if match:
+ timestamp_ms = int(match.group(1))
+ offset_str = match.group(2) if match.group(2) else "+0000"
+ timestamp = timestamp_ms / 1000.0
+
+ try:
+ offset_hours = int(offset_str[:3])
+ offset_mins = int(offset_str[3:5])
+ offset_seconds = (offset_hours * 3600) + (offset_mins * 60)
+ if offset_str[0] == "-":
+ offset_seconds = -offset_seconds
+
+ tz = timezone.utc
+ if offset_seconds != 0:
+ from datetime import timedelta as td
+
+ tz = timezone(td(seconds=offset_seconds))
+
+ return datetime.fromtimestamp(timestamp, tz=tz)
+ except (ValueError, IndexError):
+ return datetime.fromtimestamp(timestamp, tz=timezone.utc)
+
+ return None
+
+
+def _normalize_to_utc(dt: datetime) -> datetime:
+ if dt.tzinfo is not None:
+ return dt.astimezone(timezone.utc)
+ return dt.replace(tzinfo=timezone.utc)
+
+
+def parse_item_field_as_datetime(item: Dict[str, Any], field_path: str) -> Optional[datetime]:
+ """
+ Resolve field_path on item and parse to datetime (aligned with FeedManager._sort_items get_sort_value).
+ """
+ raw_data = item.get("raw", {})
+ value: Any = get_nested_value(raw_data, field_path, "")
+ if not value and field_path.startswith("raw."):
+ value = get_nested_value(raw_data, field_path[4:], "")
+ if not value:
+ value = get_nested_value(item, field_path, "")
+
+ if value is None or value == "":
+ return None
+
+ if isinstance(value, str) and value.startswith("/Date("):
+ dt = parse_microsoft_date(value)
+ if dt:
+ return dt
+
+ if isinstance(value, datetime):
+ return _normalize_to_utc(value)
+
+ if isinstance(value, (int, float)):
+ num = float(value)
+ # Heuristic: milliseconds since epoch (e.g. WSDOT-style APIs)
+ if num > 1e12:
+ num = num / 1000.0
+ try:
+ return datetime.fromtimestamp(num, tz=timezone.utc)
+ except (OSError, OverflowError, ValueError):
+ return None
+
+ if isinstance(value, str):
+ try:
+ dt = datetime.fromisoformat(value.replace("Z", "+00:00"))
+ return _normalize_to_utc(dt)
+ except ValueError:
+ pass
+ for fmt in ("%Y-%m-%dT%H:%M:%S", "%Y-%m-%d %H:%M:%S", "%Y-%m-%d"):
+ try:
+ dt = datetime.strptime(value, fmt)
+ return dt.replace(tzinfo=timezone.utc)
+ except ValueError:
+ continue
+
+ return None
+
+
+def _get_field_value_for_string_ops(item: Dict[str, Any], field_path: str) -> Any:
+ raw_data = item.get("raw", {})
+ field_value = get_nested_value(raw_data, field_path, "")
+ if not field_value and field_path.startswith("raw."):
+ field_value = get_nested_value(raw_data, field_path[4:], "")
+ if not field_value:
+ field_value = get_nested_value(item, field_path, "")
+ return field_value
+
+
+def evaluate_filter_condition(
+ item: Dict[str, Any],
+ condition: Dict[str, Any],
+ *,
+ log_warning: Optional[Callable[[str], None]] = None,
+) -> Optional[bool]:
+ """
+ Evaluate a single filter condition. Returns None if the condition should be skipped
+ (invalid / missing field path for operators that require it).
+ """
+ field_path = condition.get("field")
+ operator = condition.get("operator", "equals")
+
+ if operator in ("within_days", "within_weeks"):
+ if not field_path:
+ return None
+ include_if_missing = bool(condition.get("include_if_missing", False))
+ dt = parse_item_field_as_datetime(item, field_path)
+ if dt is None:
+ return True if include_if_missing else False
+
+ if operator == "within_days":
+ if "days" not in condition:
+ return False
+ try:
+ days = float(condition["days"])
+ except (TypeError, ValueError):
+ return False
+ else:
+ if "weeks" not in condition:
+ return False
+ try:
+ weeks = float(condition["weeks"])
+ except (TypeError, ValueError):
+ return False
+ days = weeks * 7.0
+
+ if days < 0:
+ days = 0.0
+
+ now = datetime.now(timezone.utc)
+ cutoff = now - timedelta(days=days)
+ item_dt = _normalize_to_utc(dt)
+ return item_dt >= cutoff
+
+ if not field_path:
+ return None
+
+ field_value = _get_field_value_for_string_ops(item, field_path)
+ field_value_str = str(field_value).lower() if field_value is not None else ""
+
+ if operator == "equals":
+ compare_value = str(condition.get("value", "")).lower()
+ return field_value_str == compare_value
+ if operator == "not_equals":
+ compare_value = str(condition.get("value", "")).lower()
+ return field_value_str != compare_value
+ if operator == "in":
+ values = [str(v).lower() for v in condition.get("values", [])]
+ return field_value_str in values
+ if operator == "not_in":
+ values = [str(v).lower() for v in condition.get("values", [])]
+ return field_value_str not in values
+ if operator == "matches":
+ pattern = condition.get("pattern", "")
+ if pattern:
+ try:
+ return bool(re.search(pattern, str(field_value), re.IGNORECASE))
+ except re.error:
+ return False
+ return False
+ if operator == "not_matches":
+ pattern = condition.get("pattern", "")
+ if pattern:
+ try:
+ return not bool(re.search(pattern, str(field_value), re.IGNORECASE))
+ except re.error:
+ return True
+ return True
+ if operator == "contains":
+ compare_value = str(condition.get("value", "")).lower()
+ return compare_value in field_value_str
+ if operator == "not_contains":
+ compare_value = str(condition.get("value", "")).lower()
+ return compare_value not in field_value_str
+
+ if log_warning:
+ log_warning(f"Unknown filter operator: {operator}")
+ return True
+
+
+def item_passes_filter_config(
+ item: Dict[str, Any],
+ filter_config: Union[str, Dict[str, Any], None],
+ *,
+ log_warning: Optional[Callable[[str], None]] = None,
+) -> bool:
+ """
+ Return True if the item passes all filter conditions (AND) or any (OR), matching FeedManager behavior.
+
+ Invalid JSON or empty conditions => pass through (True).
+ """
+ if not filter_config:
+ return True
+
+ try:
+ cfg = json.loads(filter_config) if isinstance(filter_config, str) else filter_config
+ except (json.JSONDecodeError, TypeError):
+ if log_warning:
+ log_warning("Invalid filter_config JSON, sending all items")
+ return True
+
+ if not isinstance(cfg, dict):
+ return True
+
+ conditions = cfg.get("conditions", [])
+ if not conditions:
+ return True
+
+ logic = str(cfg.get("logic", "AND")).upper()
+
+ results: list[bool] = []
+ for condition in conditions:
+ if not isinstance(condition, dict):
+ continue
+ out = evaluate_filter_condition(item, condition, log_warning=log_warning)
+ if out is None:
+ continue
+ results.append(out)
+
+ if not results:
+ return True
+
+ if logic == "OR":
+ return any(results)
+ return all(results)
diff --git a/modules/feed_manager.py b/modules/feed_manager.py
index 6d2dab4..47b5009 100644
--- a/modules/feed_manager.py
+++ b/modules/feed_manager.py
@@ -21,6 +21,8 @@ from urllib.parse import urlparse
import aiohttp
import feedparser
+from modules.feed_filter_eval import item_passes_filter_config
+
class FeedManager:
"""Manages RSS and API feed subscriptions"""
@@ -971,118 +973,18 @@ class FeedManager:
self._record_feed_error(feed['id'], 'queue', str(e))
def _should_send_item(self, feed: dict[str, Any], item: dict[str, Any]) -> bool:
- """Check if an item should be sent based on filter configuration
+ """Check if an item should be sent based on filter configuration.
- Filter config format:
- {
- "conditions": [
- {"field": "Priority", "operator": "in", "values": ["highest", "high"]},
- {"field": "EventStatus", "operator": "equals", "value": "open"},
- {"field": "EventCategory", "operator": "not_equals", "value": "Maintenance"},
- {"field": "raw.Priority", "operator": "matches", "pattern": "^(highest|high)$"}
- ],
- "logic": "AND" # or "OR"
- }
-
- Supported operators:
- - equals: exact match
- - not_equals: not exact match
- - in: value is in list
- - not_in: value is not in list
- - matches: regex match
- - not_matches: regex doesn't match
- - contains: substring match
- - not_contains: substring doesn't match
+ See modules/feed_filter_eval.py and docs/FEEDS.md for operators.
"""
- filter_config_str = feed.get('filter_config')
- if not filter_config_str:
- # No filter configured, send all items
- return True
+ def _warn(msg: str) -> None:
+ self.logger.warning(f"{msg} (feed id {feed.get('id')})")
- try:
- filter_config = json.loads(filter_config_str) if isinstance(filter_config_str, str) else filter_config_str
- except (json.JSONDecodeError, TypeError):
- self.logger.warning(f"Invalid filter_config for feed {feed['id']}, sending all items")
- return True
-
- conditions = filter_config.get('conditions', [])
- if not conditions:
- # Empty conditions, send all items
- return True
-
- logic = filter_config.get('logic', 'AND').upper()
-
- # Get raw data for field access
- raw_data = item.get('raw', {})
-
- # Evaluate each condition
- results = []
- for condition in conditions:
- field_path = condition.get('field')
- operator = condition.get('operator', 'equals')
-
- if not field_path:
- # Invalid condition, skip it
- continue
-
- # Get field value using nested access
- field_value = self._get_nested_value(raw_data, field_path, '')
- if not field_value and field_path.startswith('raw.'):
- # Try without 'raw.' prefix
- field_value = self._get_nested_value(raw_data, field_path[4:], '')
-
- # If still not found, try top-level item fields
- if not field_value:
- field_value = self._get_nested_value(item, field_path, '')
-
- # Convert to string for comparison
- field_value_str = str(field_value).lower() if field_value is not None else ''
-
- # Evaluate condition
- result = False
- if operator == 'equals':
- compare_value = str(condition.get('value', '')).lower()
- result = field_value_str == compare_value
- elif operator == 'not_equals':
- compare_value = str(condition.get('value', '')).lower()
- result = field_value_str != compare_value
- elif operator == 'in':
- values = [str(v).lower() for v in condition.get('values', [])]
- result = field_value_str in values
- elif operator == 'not_in':
- values = [str(v).lower() for v in condition.get('values', [])]
- result = field_value_str not in values
- elif operator == 'matches':
- pattern = condition.get('pattern', '')
- if pattern:
- try:
- result = bool(re.search(pattern, str(field_value), re.IGNORECASE))
- except re.error:
- result = False
- elif operator == 'not_matches':
- pattern = condition.get('pattern', '')
- if pattern:
- try:
- result = not bool(re.search(pattern, str(field_value), re.IGNORECASE))
- except re.error:
- result = True
- elif operator == 'contains':
- compare_value = str(condition.get('value', '')).lower()
- result = compare_value in field_value_str
- elif operator == 'not_contains':
- compare_value = str(condition.get('value', '')).lower()
- result = compare_value not in field_value_str
- else:
- self.logger.warning(f"Unknown filter operator: {operator}")
- result = True # Default to allowing if operator is unknown
-
- results.append(result)
-
- # Apply logic (AND or OR)
- if logic == 'OR':
- return any(results)
- else: # AND (default)
- return all(results)
+ return item_passes_filter_config(
+ item,
+ feed.get('filter_config'),
+ log_warning=_warn,
+ )
async def _send_feed_item(self, feed: dict[str, Any], item: dict[str, Any]):
"""Queue a feed item message instead of sending immediately"""
diff --git a/modules/web_viewer/app.py b/modules/web_viewer/app.py
index 06e11d9..11a3ba0 100644
--- a/modules/web_viewer/app.py
+++ b/modules/web_viewer/app.py
@@ -4968,6 +4968,8 @@ class BotDataViewer:
api_config = data.get('api_config')
output_format = data.get('output_format')
message_send_interval = data.get('message_send_interval_seconds')
+ filter_config = data.get('filter_config')
+ sort_config = data.get('sort_config')
if not all([feed_type, feed_url, channel_name]):
raise ValueError("feed_type, feed_url, and channel_name are required")
@@ -4976,12 +4978,14 @@ class BotDataViewer:
cursor = conn.cursor()
api_config_str = json.dumps(api_config) if api_config else None
+ filter_config_str = json.dumps(filter_config) if filter_config else None
+ sort_config_str = json.dumps(sort_config) if sort_config else None
cursor.execute('''
INSERT INTO feed_subscriptions
- (feed_type, feed_url, channel_name, feed_name, check_interval_seconds, api_config, output_format, message_send_interval_seconds)
- VALUES (?, ?, ?, ?, ?, ?, ?, ?)
- ''', (feed_type, feed_url, channel_name, feed_name, check_interval, api_config_str, output_format, message_send_interval))
+ (feed_type, feed_url, channel_name, feed_name, check_interval_seconds, api_config, output_format, message_send_interval_seconds, filter_config, sort_config)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
+ ''', (feed_type, feed_url, channel_name, feed_name, check_interval, api_config_str, output_format, message_send_interval, filter_config_str, sort_config_str))
conn.commit()
return cursor.lastrowid
@@ -5036,10 +5040,6 @@ class BotDataViewer:
updates.append('sort_config = ?')
params.append(json.dumps(data['sort_config']) if data['sort_config'] else None)
- if 'message_send_interval_seconds' in data:
- updates.append('message_send_interval_seconds = ?')
- params.append(data['message_send_interval_seconds'])
-
if not updates:
return True # Nothing to update
@@ -5540,115 +5540,10 @@ class BotDataViewer:
raise
def _should_include_item(self, item: dict[str, Any], filter_config: dict) -> bool:
- """Check if an item should be included based on filter configuration (standalone version for preview)"""
- import json
- import re
+ """Check if an item should be included based on filter configuration (preview; same rules as FeedManager)."""
+ from modules.feed_filter_eval import item_passes_filter_config
- if not filter_config:
- return True
-
- try:
- filter_config_dict = json.loads(filter_config) if isinstance(filter_config, str) else filter_config
- except (json.JSONDecodeError, TypeError):
- return True
-
- conditions = filter_config_dict.get('conditions', [])
- if not conditions:
- return True
-
- logic = filter_config_dict.get('logic', 'AND').upper()
-
- # Get raw data for field access
- raw_data = item.get('raw', {})
-
- # Helper to get nested values
- def get_nested_value(data, path, default=''):
- if not path or not data:
- return default
- parts = path.split('.')
- value = data
- for part in parts:
- if isinstance(value, dict):
- value = value.get(part)
- elif isinstance(value, list):
- try:
- idx = int(part)
- if 0 <= idx < len(value):
- value = value[idx]
- else:
- return default
- except (ValueError, TypeError):
- return default
- else:
- return default
- if value is None:
- return default
- return value if value is not None else default
-
- # Evaluate each condition
- results = []
- for condition in conditions:
- field_path = condition.get('field')
- operator = condition.get('operator', 'equals')
-
- if not field_path:
- continue
-
- # Get field value using nested access
- field_value = get_nested_value(raw_data, field_path, '')
- if not field_value and field_path.startswith('raw.'):
- field_value = get_nested_value(raw_data, field_path[4:], '')
-
- if not field_value:
- field_value = get_nested_value(item, field_path, '')
-
- # Convert to string for comparison
- field_value_str = str(field_value).lower() if field_value is not None else ''
-
- # Evaluate condition
- result = False
- if operator == 'equals':
- compare_value = str(condition.get('value', '')).lower()
- result = field_value_str == compare_value
- elif operator == 'not_equals':
- compare_value = str(condition.get('value', '')).lower()
- result = field_value_str != compare_value
- elif operator == 'in':
- values = [str(v).lower() for v in condition.get('values', [])]
- result = field_value_str in values
- elif operator == 'not_in':
- values = [str(v).lower() for v in condition.get('values', [])]
- result = field_value_str not in values
- elif operator == 'matches':
- pattern = condition.get('pattern', '')
- if pattern:
- try:
- result = bool(re.search(pattern, str(field_value), re.IGNORECASE))
- except re.error:
- result = False
- elif operator == 'not_matches':
- pattern = condition.get('pattern', '')
- if pattern:
- try:
- result = not bool(re.search(pattern, str(field_value), re.IGNORECASE))
- except re.error:
- result = True
- elif operator == 'contains':
- compare_value = str(condition.get('value', '')).lower()
- result = compare_value in field_value_str
- elif operator == 'not_contains':
- compare_value = str(condition.get('value', '')).lower()
- result = compare_value not in field_value_str
- else:
- result = True # Default to allowing if operator is unknown
-
- results.append(result)
-
- # Apply logic (AND or OR)
- if logic == 'OR':
- return any(results)
- else: # AND (default)
- return all(results)
+ return item_passes_filter_config(item, filter_config)
def _parse_microsoft_date(self, date_str: str) -> Optional[datetime]:
"""Parse Microsoft JSON date format: /Date(timestamp-offset)/"""
diff --git a/modules/web_viewer/templates/feeds.html b/modules/web_viewer/templates/feeds.html
index a94f2b5..6102981 100644
--- a/modules/web_viewer/templates/feeds.html
+++ b/modules/web_viewer/templates/feeds.html
@@ -242,9 +242,9 @@
}'>
Only send items that match these conditions. Leave empty to send all items.
- Operators: equals, not_equals, in, not_in, matches (regex), not_matches, contains, not_contains
+ Operators: equals, not_equals, in, not_in, matches (regex), not_matches, contains, not_contains, within_days, within_weeks (see docs/FEEDS.md)
Logic: AND (all conditions) or OR (any condition)
- Fields: Use raw.field or raw.nested.field for API fields
+ Fields: Use raw.field or raw.nested.field for API fields; use published for RSS time filters
diff --git a/tests/test_feed_filter_eval.py b/tests/test_feed_filter_eval.py
new file mode 100644
index 0000000..18e4d6c
--- /dev/null
+++ b/tests/test_feed_filter_eval.py
@@ -0,0 +1,136 @@
+"""Unit tests for modules.feed_filter_eval (filter_config parsing and time windows)."""
+
+from datetime import datetime, timedelta, timezone
+
+from modules.feed_filter_eval import (
+ item_passes_filter_config,
+ parse_item_field_as_datetime,
+ parse_microsoft_date,
+)
+
+
+def _utc_now():
+ return datetime.now(timezone.utc)
+
+
+class TestParseItemFieldAsDatetime:
+ def test_published_datetime(self):
+ dt = _utc_now() - timedelta(days=1)
+ item = {"published": dt}
+ assert parse_item_field_as_datetime(item, "published") is not None
+
+ def test_microsoft_date_string(self):
+ # /Date(ms)/ format
+ ms = int(_utc_now().timestamp() * 1000)
+ s = f"/Date({ms})/"
+ item = {"raw": {"t": s}}
+ assert parse_item_field_as_datetime(item, "raw.t") is not None
+
+ def test_iso_string_in_raw(self):
+ item = {"raw": {"when": "2024-06-01T12:00:00Z"}}
+ out = parse_item_field_as_datetime(item, "raw.when")
+ assert out is not None
+ assert out.year == 2024
+
+
+class TestWithinDaysWeeks:
+ def test_within_days_recent_passes(self):
+ now = _utc_now()
+ item = {"published": now - timedelta(days=5)}
+ cfg = {
+ "conditions": [
+ {"field": "published", "operator": "within_days", "days": 28},
+ ],
+ "logic": "AND",
+ }
+ assert item_passes_filter_config(item, cfg) is True
+
+ def test_within_days_old_fails(self):
+ now = _utc_now()
+ item = {"published": now - timedelta(days=40)}
+ cfg = {
+ "conditions": [
+ {"field": "published", "operator": "within_days", "days": 28},
+ ],
+ "logic": "AND",
+ }
+ assert item_passes_filter_config(item, cfg) is False
+
+ def test_within_weeks_equivalent(self):
+ now = _utc_now()
+ item = {"published": now - timedelta(days=10)}
+ cfg = {
+ "conditions": [
+ {"field": "published", "operator": "within_weeks", "weeks": 1},
+ ],
+ "logic": "AND",
+ }
+ assert item_passes_filter_config(item, cfg) is False
+
+ def test_within_weeks_recent_passes(self):
+ now = _utc_now()
+ item = {"published": now - timedelta(days=5)}
+ cfg = {
+ "conditions": [
+ {"field": "published", "operator": "within_weeks", "weeks": 1},
+ ],
+ "logic": "AND",
+ }
+ assert item_passes_filter_config(item, cfg) is True
+
+ def test_missing_date_strict_fails(self):
+ item = {"title": "x"}
+ cfg = {
+ "conditions": [
+ {"field": "published", "operator": "within_days", "days": 7},
+ ],
+ "logic": "AND",
+ }
+ assert item_passes_filter_config(item, cfg) is False
+
+ def test_missing_date_include_if_missing(self):
+ item = {"title": "x"}
+ cfg = {
+ "conditions": [
+ {
+ "field": "published",
+ "operator": "within_days",
+ "days": 7,
+ "include_if_missing": True,
+ },
+ ],
+ "logic": "AND",
+ }
+ assert item_passes_filter_config(item, cfg) is True
+
+ def test_within_days_without_days_key_fails(self):
+ item = {"published": _utc_now()}
+ cfg = {
+ "conditions": [
+ {"field": "published", "operator": "within_days"},
+ ],
+ "logic": "AND",
+ }
+ assert item_passes_filter_config(item, cfg) is False
+
+ def test_combined_with_priority(self):
+ now = _utc_now()
+ item = {
+ "published": now - timedelta(days=1),
+ "raw": {"Priority": "high"},
+ }
+ cfg = {
+ "conditions": [
+ {"field": "published", "operator": "within_days", "days": 28},
+ {"field": "Priority", "operator": "equals", "value": "high"},
+ ],
+ "logic": "AND",
+ }
+ assert item_passes_filter_config(item, cfg) is True
+
+
+class TestMicrosoftDateParse:
+ def test_parse_microsoft_date(self):
+ ms = int(_utc_now().timestamp() * 1000)
+ dt = parse_microsoft_date(f"/Date({ms}+0000)/")
+ assert dt is not None
diff --git a/tests/test_feed_manager_extended.py b/tests/test_feed_manager_extended.py
new file mode 100644
index 0000000..16ee16c
--- /dev/null
+++ b/tests/test_feed_manager_extended.py
@@ -0,0 +1,299 @@
+"""Extended FeedManager tests: sort, format_message, mocked RSS/API fetch, queue processing."""
+
+from __future__ import annotations
+
+import json
+from configparser import ConfigParser
+from datetime import datetime, timezone
+from unittest.mock import AsyncMock, MagicMock, Mock
+
+import pytest
+
+from modules.db_manager import DBManager
+from modules.feed_manager import FeedManager
+
+
+def _feed_manager_bot(mock_logger, db_path: str):
+ bot = Mock()
+ bot.logger = mock_logger
+ bot.config = ConfigParser()
+ bot.config.add_section("Feed_Manager")
+ bot.config.set("Feed_Manager", "feed_manager_enabled", "false")
+ bot.config.set("Feed_Manager", "max_message_length", "200")
+ bot.db_manager = DBManager(bot, db_path)
+ return bot
+
+
+@pytest.fixture
+def fm_with_db(mock_logger, tmp_path):
+ """FeedManager backed by a real file SQLite DB (feed tables from DBManager)."""
+ db_path = str(tmp_path / "feeds.db")
+ bot = _feed_manager_bot(mock_logger, db_path)
+ return FeedManager(bot)
+
+
+def _fake_aiohttp_response(*, text_body: str | None = None, json_body: dict | list | None = None):
+ """Build a minimal async context manager compatible with async with session.get/post."""
+ resp = Mock()
+ resp.status = 200
+ if text_body is not None:
+ resp.text = AsyncMock(return_value=text_body)
+ if json_body is not None:
+ resp.json = AsyncMock(return_value=json_body)
+
+ class _CM:
+ def __init__(self, r):
+ self._r = r
+
+ async def __aenter__(self):
+ return self._r
+
+ async def __aexit__(self, exc_type, exc, tb):
+ return False
+
+ return _CM(resp)
+
+
+class TestSortItems:
+ def test_sort_by_published_desc(self, fm_with_db):
+ fm = fm_with_db
+ older = datetime(2020, 1, 1, tzinfo=timezone.utc)
+ newer = datetime(2025, 6, 1, tzinfo=timezone.utc)
+ items = [
+ {"id": "a", "title": "old", "published": older, "raw": {}},
+ {"id": "b", "title": "new", "published": newer, "raw": {}},
+ ]
+ out = fm._sort_items(items, {"field": "published", "order": "desc"})
+ assert [x["id"] for x in out] == ["b", "a"]
+
+ def test_sort_by_raw_numeric_timestamp_asc(self, fm_with_db):
+ fm = fm_with_db
+ items = [
+ {"id": "2", "title": "t2", "raw": {"t": 200.0}, "published": None},
+ {"id": "1", "title": "t1", "raw": {"t": 100.0}, "published": None},
+ ]
+ out = fm._sort_items(items, {"field": "raw.t", "order": "asc"})
+ assert [x["id"] for x in out] == ["1", "2"]
+
+ def test_sort_empty_field_returns_unchanged(self, fm_with_db):
+ fm = fm_with_db
+ items = [{"id": "x", "title": "a"}]
+ out = fm._sort_items(items, {"field": "", "order": "desc"})
+ assert out == items
+
+
+class TestFormatMessage:
+ def test_basic_placeholders(self, fm_with_db):
+ fm = fm_with_db
+ now = datetime.now(timezone.utc)
+ feed = {"output_format": "{emoji} {title}\n{link}\n{date}", "feed_name": "news"}
+ item = {
+ "title": "Hello",
+ "link": "https://ex.com/a",
+ "description": "",
+ "published": now,
+ "raw": {},
+ }
+ msg = fm.format_message(item, feed)
+ assert "Hello" in msg
+ assert "https://ex.com/a" in msg
+ assert "📢" in msg or "ℹ️" in msg # emoji from feed_name or default
+
+ def test_strips_br_and_html_from_body(self, fm_with_db):
+ fm = fm_with_db
+ feed = {"output_format": "{body}"}
+ item = {
+ "title": "t",
+ "description": 'Line1
Line2
Para
', + "published": None, + "raw": {}, + } + msg = fm.format_message(item, feed) + assert "