mirror of
https://github.com/agessaman/meshcore-bot.git
synced 2026-08-28 22:18:16 +00:00
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:
@@ -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
|
||||
|
||||
+73
-2
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user