diff --git a/docs/FEEDS.md b/docs/FEEDS.md index 9e767fc..0604d57 100644 --- a/docs/FEEDS.md +++ b/docs/FEEDS.md @@ -193,6 +193,8 @@ The output format string controls how feed items are formatted before sending to Apply functions to placeholders using the pipe operator: +- `{field|auto}` - Use the **remaining** characters up to `max_message_length` (from `[Feed_Manager]`). The format string is read **left to right**: every placeholder **before** `{field|auto}` is rendered, then every placeholder **after** it; the space left in the message is filled with that field’s text. If the text is longer than that space, it is cut with `...` (same idea as `truncate:N`). Use **at most one** `{field|auto}` per format. If more than one appears, the bot logs a warning, **only the first** expands, and any extra `{field|auto}` render **empty**. If the fixed prefix and suffix already exceed `max_message_length`, the auto segment is empty and the normal end-of-message truncation may still run. + - `{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 diff --git a/modules/feed_manager.py b/modules/feed_manager.py index 65849d7..d4bb63f 100644 --- a/modules/feed_manager.py +++ b/modules/feed_manager.py @@ -505,6 +505,54 @@ class FeedManager: except Exception: return "" + @staticmethod + def _feed_format_auto_slots(format_str: str) -> list[tuple[int, int, str]]: + """Return (start, end, field_name) for each {field|auto} placeholder (left-to-right).""" + slots: list[tuple[int, int, str]] = [] + for m in re.finditer(r"\{([^}]+)\}", format_str): + content = m.group(1) + if "|" not in content: + continue + field_name, function = content.split("|", 1) + if function.strip() == "auto": + slots.append((m.start(), m.end(), field_name.strip())) + return slots + + @staticmethod + def _truncate_to_budget(text: str, budget: int) -> str: + """Fit text to at most budget characters; ellipsis when budget > 3 (same idea as truncate:N).""" + if budget <= 0: + return "" + if not text: + return "" + if len(text) <= budget: + return text + if budget > 3: + return text[: budget - 3] + "..." + return text[:budget] + + def _feed_format_auto_base_value( + self, + field_name: str, + raw_data: Any, + replacements: dict[str, str], + link_original: str, + ) -> str: + """Full string for one field before |auto (long link, no shorten).""" + if field_name.startswith("raw."): + value = self._get_nested_value(raw_data, field_name[4:], "") + if value is None: + return "" + if isinstance(value, (dict, list)): + try: + return json.dumps(value) + except Exception: + return str(value) + return str(value) + if field_name == "link": + return link_original or "" + return str(replacements.get(field_name, "") or "") + def _apply_shortening(self, text: str, function: str) -> str: """Apply a shortening, parsing, or conditional function to text @@ -869,6 +917,7 @@ class FeedManager: - {field|if_regex:pattern:then:else} - if pattern matches, return "then", else "else" - {field|switch:value1:result1:value2:result2:...:default} - exact match switch (e.g., switch:highest:🔴:high:🟠:medium:🟡:⚪) - {field|regex_cond:extract_pattern:check_pattern:then:group} - extract text, check if it matches check_pattern, return "then" if match else extracted text + - {field|auto} - fill remaining characters up to max_message_length (at most one per format; see docs) """ # Get format string from feed config or use default @@ -931,6 +980,8 @@ class FeedManager: field_name, function = content.split('|', 1) field_name = field_name.strip() function = function.strip() + if function == 'auto': + return '' # Check if it's a raw field access if field_name.startswith('raw.'): @@ -979,8 +1030,28 @@ class FeedManager: else: return replacements.get(field_name, '') - # Replace all placeholders - message = re.sub(r'\{([^}]+)\}', replace_placeholder, format_str) + auto_slots = self._feed_format_auto_slots(format_str) + if len(auto_slots) > 1: + self.logger.warning( + "Multiple {field|auto} placeholders in feed output format; " + "only the first expands. Others render empty. (feed id %s)", + feed.get("id"), + ) + + if len(auto_slots) >= 1: + start, end, auto_field = auto_slots[0] + prefix = format_str[:start] + suffix = format_str[end:] + prefix_r = re.sub(r"\{([^}]+)\}", replace_placeholder, prefix) + suffix_r = re.sub(r"\{([^}]+)\}", replace_placeholder, suffix) + budget = self.max_message_length - len(prefix_r) - len(suffix_r) + raw_auto = self._feed_format_auto_base_value( + auto_field, raw_data, replacements, link_original + ) + auto_text = self._truncate_to_budget(raw_auto, budget) + message = prefix_r + auto_text + suffix_r + else: + message = re.sub(r"\{([^}]+)\}", replace_placeholder, format_str) # Final truncation if message is too long if len(message) > self.max_message_length: diff --git a/modules/web_viewer/app.py b/modules/web_viewer/app.py index e87de70..982d611 100644 --- a/modules/web_viewer/app.py +++ b/modules/web_viewer/app.py @@ -69,11 +69,12 @@ _apply_werkzeug_websocket_fix() project_root = os.path.join(os.path.dirname(__file__), '..', '..') sys.path.insert(0, project_root) +from modules.feed_manager import FeedManager from modules.repeater_manager import RepeaterManager +from modules.url_shortener import _coerce_url_string from modules.utils import calculate_distance, resolve_path from modules.web_viewer.config_panels import CONFIG_PANELS, PANEL_CATEGORIES - class BotDataViewer: """Complete web interface using Flask-SocketIO 5.x best practices""" @@ -5700,7 +5701,7 @@ class BotDataViewer: body = '\n'.join(' '.join(line.split()) for line in lines) # Normalize spaces per line body = body.strip() - link = item.get('link', '') + link_original = _coerce_url_string(item.get('link', '')) published = item.get('published') # Format timestamp @@ -5741,7 +5742,7 @@ class BotDataViewer: 'title': title, 'body': body, 'date': date_str, - 'link': link, + 'link': link_original, 'emoji': emoji } @@ -5948,6 +5949,21 @@ class BotDataViewer: return text return text + def _preview_auto_base_value(field_name: str) -> str: + if field_name.startswith('raw.'): + value = get_nested_value(raw_data, field_name[4:], '') + if value is None: + return '' + if isinstance(value, (dict, list)): + try: + return json.dumps(value) + except Exception: + return str(value) + return str(value) + if field_name == 'link': + return link_original or '' + return str(replacements.get(field_name, '') or '') + # Process format string def replace_placeholder(match): content = match.group(1) @@ -5955,6 +5971,8 @@ class BotDataViewer: field_name, function = content.split('|', 1) field_name = field_name.strip() function = function.strip() + if function == 'auto': + return '' # Check if it's a raw field access if field_name.startswith('raw.'): @@ -5982,10 +6000,34 @@ class BotDataViewer: else: return replacements.get(field_name, '') - message = re.sub(r'\{([^}]+)\}', replace_placeholder, format_str) + try: + max_length = self.config.getint( + 'Feed_Manager', 'max_message_length', fallback=130 + ) + except Exception: + max_length = 130 - # Final truncation (130 char limit) - max_length = 130 + auto_slots = FeedManager._feed_format_auto_slots(format_str) + if len(auto_slots) > 1: + self.logger.warning( + 'Multiple {field|auto} placeholders in feed output format; ' + 'only the first expands. Others render empty.' + ) + + if len(auto_slots) >= 1: + start, end, auto_field = auto_slots[0] + prefix = format_str[:start] + suffix = format_str[end:] + prefix_r = re.sub(r'\{([^}]+)\}', replace_placeholder, prefix) + suffix_r = re.sub(r'\{([^}]+)\}', replace_placeholder, suffix) + budget = max_length - len(prefix_r) - len(suffix_r) + raw_auto = _preview_auto_base_value(auto_field) + auto_text = FeedManager._truncate_to_budget(raw_auto, budget) + message = prefix_r + auto_text + suffix_r + else: + message = re.sub(r'\{([^}]+)\}', replace_placeholder, format_str) + + # Final truncation (mesh limit) if len(message) > max_length: lines = message.split('\n') if len(lines) > 1: diff --git a/tests/test_feed_manager_extended.py b/tests/test_feed_manager_extended.py index ddaaab3..40f613d 100644 --- a/tests/test_feed_manager_extended.py +++ b/tests/test_feed_manager_extended.py @@ -267,6 +267,69 @@ class TestFormatMessage: assert len(msg) <= 15 # 12 + "..." assert msg.endswith("...") + def test_title_auto_fits_max_message_length(self, fm_with_db): + fm = fm_with_db + fm.max_message_length = 40 + feed = {"output_format": "{emoji} {title|auto}\nD", "feed_name": "x"} + item = { + "title": "A" * 100, + "link": "", + "description": "", + "published": None, + "raw": {}, + } + msg = fm.format_message(item, feed) + assert len(msg) <= 40 + + def test_auto_when_prefix_exceeds_max_uses_final_truncation(self, fm_with_db): + fm = fm_with_db + fm.max_message_length = 20 + feed = {"output_format": "{title}{title|auto}", "feed_name": "x"} + item = { + "title": "B" * 25, + "link": "", + "description": "", + "published": None, + "raw": {}, + } + msg = fm.format_message(item, feed) + assert len(msg) <= 23 # max_message_length + "..." + + def test_multiple_auto_warning_second_renders_empty(self, fm_with_db, mock_logger): + fm = fm_with_db + fm.max_message_length = 120 + feed = { + "output_format": "{title|auto}|X|{body|auto}", + "feed_name": "x", + "id": 99, + } + item = { + "title": "Hello", + "link": "", + "description": "ignored", + "published": None, + "raw": {}, + } + msg = fm.format_message(item, feed) + mock_logger.warning.assert_called() + assert msg == "Hello|X|" + + def test_body_auto_multiline(self, fm_with_db): + fm = fm_with_db + fm.max_message_length = 30 + feed = {"output_format": "H\n{body|auto}\nT", "feed_name": "x"} + item = { + "title": "t", + "link": "", + "description": "line1\nline2\n" + "Z" * 50, + "published": None, + "raw": {}, + } + msg = fm.format_message(item, feed) + assert len(msg) <= 30 + assert msg.startswith("H\n") + assert msg.endswith("\nT") + class TestProcessRssFeed: @pytest.mark.asyncio