Enhance feed message formatting with auto field functionality

- Introduced `{field|auto}` placeholder in message formats to fill remaining characters up to `max_message_length`, improving message customization.
- Implemented logic in `FeedManager` to handle multiple `{field|auto}` placeholders, logging a warning if more than one is present.
- Updated `BotDataViewer` to utilize the new auto field feature, ensuring compatibility with existing message formatting.
- Added unit tests to validate the behavior of the new auto field functionality, including handling of message length constraints and multiple placeholders.
This commit is contained in:
agessaman
2026-03-21 20:46:14 -07:00
parent 6a8ad5260a
commit 9f644f57d0
4 changed files with 186 additions and 8 deletions
+2
View File
@@ -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 fields 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
+73 -2
View File
@@ -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:
+48 -6
View File
@@ -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:
+63
View File
@@ -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