fix(signal): redistribute textStyle ranges across split message chunks

split_message can break a long Signal payload into multiple JSON-RPC sends,
but the previous code attached the full textStyle list only to chunk 0.
Style ranges in later chunks were dropped, and ranges whose offsets pointed
past chunk 0's end were sent as invalid metadata against chunk 0.

Add _partition_styles, which rebases each range against the chunk it lives
in (in UTF-16 code units, matching the markdown converter) and splits
boundary-spanning ranges across the chunks they touch. Whitespace trimmed
by split_message's lstrip is skipped so offsets stay aligned.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Kaloyan Tenchov
2026-05-21 01:00:36 +08:00
committed by Xubin Ren
co-authored by Claude Opus 4.7
parent 8f6b7611a2
commit 96eb3b7194
3 changed files with 192 additions and 3 deletions
+51 -2
View File
@@ -208,6 +208,54 @@ def _markdown_to_signal(text: str) -> tuple[str, list[str]]:
return plain_text, text_styles
def _partition_styles(
plain_text: str, chunks: list[str], text_styles: list[str]
) -> list[list[str]]:
"""Partition Signal textStyle ranges across message chunks.
``split_message`` slices ``plain_text`` into pieces (optionally trimming
whitespace at the boundaries), but the style ranges produced by
``_markdown_to_signal`` are expressed in UTF-16 offsets relative to the
full ``plain_text``. This redistributes them per chunk with offsets
rebased to each chunk's start. Ranges that span a boundary are split
across the chunks they touch; ranges that fall entirely in trimmed
whitespace are dropped.
"""
if not chunks:
return []
if not text_styles:
return [[] for _ in chunks]
# Locate each chunk's UTF-16 start in plain_text. split_message lstrips at
# boundaries (but not before the first chunk), so we skip whitespace
# between chunks to mirror that.
chunk_ranges: list[tuple[int, int]] = []
cursor = 0 # Python codepoint cursor in plain_text
for i, chunk in enumerate(chunks):
if i > 0:
while cursor < len(plain_text) and plain_text[cursor].isspace():
cursor += 1
utf16_start = _utf16_len(plain_text[:cursor])
utf16_end = utf16_start + _utf16_len(chunk)
chunk_ranges.append((utf16_start, utf16_end))
cursor += len(chunk)
result: list[list[str]] = [[] for _ in chunks]
for entry in text_styles:
s, ln, style = entry.split(":", 2)
r_start = int(s)
r_end = r_start + int(ln)
for i, (c_start, c_end) in enumerate(chunk_ranges):
if r_end <= c_start or r_start >= c_end:
continue
new_start = max(r_start, c_start) - c_start
new_end = min(r_end, c_end) - c_start
new_length = new_end - new_start
if new_length > 0:
result[i].append(f"{new_start}:{new_length}:{style}")
return result
class SignalDMConfig(Base):
"""Signal DM policy configuration."""
@@ -392,10 +440,11 @@ class SignalChannel(BaseChannel):
recipient_params = self._recipient_params(msg.chat_id)
chunks = split_message(plain_text, self._MAX_MESSAGE_LEN) if plain_text else [""]
chunk_styles = _partition_styles(plain_text, chunks, text_styles)
for i, chunk in enumerate(chunks):
params: dict[str, Any] = {"message": chunk}
if text_styles and i == 0:
params["textStyle"] = text_styles
if chunk_styles[i]:
params["textStyle"] = chunk_styles[i]
params.update(recipient_params)
if msg.media and i == 0:
params["attachments"] = msg.media