mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-05-21 09:02:32 +00:00
feat(signal): add Signal channel support
Integrates signal-cli daemon via HTTP JSON-RPC as a nanobot channel. Supports DMs and group chats with open/allowlist access policies, markdown→Signal text style conversion, typing indicators, attachment handling, group message context buffering, and automatic reconnect with exponential backoff. Includes unit tests for channel lifecycle, message routing, mention detection, markdown conversion, and message splitting. Originally based on https://github.com/HKUDS/nanobot/pull/601.
This commit is contained in:
parent
e02615c93d
commit
880097acd5
1133
nanobot/channels/signal.py
Normal file
1133
nanobot/channels/signal.py
Normal file
File diff suppressed because it is too large
Load Diff
1058
tests/channels/test_signal_channel.py
Normal file
1058
tests/channels/test_signal_channel.py
Normal file
File diff suppressed because it is too large
Load Diff
244
tests/channels/test_signal_markdown.py
Normal file
244
tests/channels/test_signal_markdown.py
Normal file
@ -0,0 +1,244 @@
|
||||
"""Unit tests for the Signal markdown → plain text + textStyle converter."""
|
||||
|
||||
from nanobot.channels.signal import _markdown_to_signal
|
||||
|
||||
|
||||
def styles_for(plain: str, text_styles: list[str]) -> dict[str, list[str]]:
|
||||
"""Return a dict mapping each styled substring to its style list."""
|
||||
result: dict[str, list[str]] = {}
|
||||
for entry in text_styles:
|
||||
start_s, length_s, style = entry.split(":", 2)
|
||||
start, length = int(start_s), int(length_s)
|
||||
span = plain[start : start + length]
|
||||
result.setdefault(span, []).append(style)
|
||||
return result
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Basic cases
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_empty():
|
||||
plain, styles = _markdown_to_signal("")
|
||||
assert plain == ""
|
||||
assert styles == []
|
||||
|
||||
|
||||
def test_plain_text():
|
||||
plain, styles = _markdown_to_signal("hello world")
|
||||
assert plain == "hello world"
|
||||
assert styles == []
|
||||
|
||||
|
||||
def test_bold_stars():
|
||||
plain, styles = _markdown_to_signal("say **hello** now")
|
||||
assert plain == "say hello now"
|
||||
assert styles_for(plain, styles) == {"hello": ["BOLD"]}
|
||||
|
||||
|
||||
def test_bold_underscores():
|
||||
plain, styles = _markdown_to_signal("say __hello__ now")
|
||||
assert plain == "say hello now"
|
||||
assert styles_for(plain, styles) == {"hello": ["BOLD"]}
|
||||
|
||||
|
||||
def test_italic_star():
|
||||
plain, styles = _markdown_to_signal("say *hello* now")
|
||||
assert plain == "say hello now"
|
||||
assert styles_for(plain, styles) == {"hello": ["ITALIC"]}
|
||||
|
||||
|
||||
def test_italic_underscore():
|
||||
plain, styles = _markdown_to_signal("say _hello_ now")
|
||||
assert plain == "say hello now"
|
||||
assert styles_for(plain, styles) == {"hello": ["ITALIC"]}
|
||||
|
||||
|
||||
def test_strikethrough():
|
||||
plain, styles = _markdown_to_signal("say ~~hello~~ now")
|
||||
assert plain == "say hello now"
|
||||
assert styles_for(plain, styles) == {"hello": ["STRIKETHROUGH"]}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Code
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_inline_code():
|
||||
plain, styles = _markdown_to_signal("run `ls -la` here")
|
||||
assert plain == "run ls -la here"
|
||||
assert styles_for(plain, styles) == {"ls -la": ["MONOSPACE"]}
|
||||
|
||||
|
||||
def test_code_block():
|
||||
plain, styles = _markdown_to_signal("```\nprint('hi')\n```")
|
||||
assert "print('hi')" in plain
|
||||
assert styles_for(plain, styles).get("print('hi')\n") == ["MONOSPACE"] or \
|
||||
"MONOSPACE" in str(styles_for(plain, styles))
|
||||
|
||||
|
||||
def test_code_block_with_lang():
|
||||
plain, styles = _markdown_to_signal("```python\ncode\n```")
|
||||
assert "code" in plain
|
||||
assert any("MONOSPACE" in s for s in styles)
|
||||
|
||||
|
||||
def test_code_block_not_processed_further():
|
||||
"""Markdown inside a code block must not be styled."""
|
||||
plain, styles = _markdown_to_signal("```\n**not bold**\n```")
|
||||
assert "**not bold**" in plain
|
||||
# Only MONOSPACE should be applied, no BOLD
|
||||
for entry in styles:
|
||||
assert "BOLD" not in entry
|
||||
|
||||
|
||||
def test_inline_code_not_processed_further():
|
||||
"""Markdown inside inline code must not be styled."""
|
||||
plain, styles = _markdown_to_signal("use `**raw**` please")
|
||||
assert "**raw**" in plain
|
||||
for entry in styles:
|
||||
assert "BOLD" not in entry
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Headers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_header_becomes_bold():
|
||||
plain, styles = _markdown_to_signal("# My Title")
|
||||
assert plain == "My Title"
|
||||
assert styles_for(plain, styles) == {"My Title": ["BOLD"]}
|
||||
|
||||
|
||||
def test_h2_becomes_bold():
|
||||
plain, styles = _markdown_to_signal("## Sub-section")
|
||||
assert plain == "Sub-section"
|
||||
assert styles_for(plain, styles) == {"Sub-section": ["BOLD"]}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Blockquotes
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_blockquote_strips_marker():
|
||||
plain, styles = _markdown_to_signal("> some quote")
|
||||
assert plain == "some quote"
|
||||
assert styles == []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Lists
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_bullet_dash():
|
||||
plain, styles = _markdown_to_signal("- item one")
|
||||
assert plain == "• item one"
|
||||
|
||||
|
||||
def test_bullet_star():
|
||||
plain, styles = _markdown_to_signal("* item two")
|
||||
assert plain == "• item two"
|
||||
|
||||
|
||||
def test_numbered_list():
|
||||
plain, styles = _markdown_to_signal("1. first\n2. second")
|
||||
assert "1. first" in plain
|
||||
assert "2. second" in plain
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Links
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_link_text_differs_from_url():
|
||||
plain, styles = _markdown_to_signal("[Click here](https://example.com)")
|
||||
assert plain == "Click here (https://example.com)"
|
||||
assert styles == []
|
||||
|
||||
|
||||
def test_link_text_equals_url():
|
||||
plain, styles = _markdown_to_signal("[https://example.com](https://example.com)")
|
||||
assert plain == "https://example.com"
|
||||
assert styles == []
|
||||
|
||||
|
||||
def test_link_text_equals_url_without_scheme():
|
||||
plain, styles = _markdown_to_signal("[example.com](https://example.com)")
|
||||
assert plain == "https://example.com"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Mixed / nesting
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_bold_and_italic_adjacent():
|
||||
plain, styles = _markdown_to_signal("**bold** and *italic*")
|
||||
assert plain == "bold and italic"
|
||||
sd = styles_for(plain, styles)
|
||||
assert sd.get("bold") == ["BOLD"]
|
||||
assert sd.get("italic") == ["ITALIC"]
|
||||
|
||||
|
||||
def test_header_with_inline_code():
|
||||
"""Header becomes BOLD; code inside becomes MONOSPACE (not double-BOLD)."""
|
||||
plain, styles = _markdown_to_signal("# Use `grep`")
|
||||
assert plain == "Use grep"
|
||||
sd = styles_for(plain, styles)
|
||||
assert "BOLD" in sd.get("Use ", []) or "BOLD" in str(styles)
|
||||
assert "MONOSPACE" in sd.get("grep", [])
|
||||
|
||||
|
||||
def test_multiline_mixed():
|
||||
md = "**Title**\n\nSome *italic* text.\n\n- bullet\n- another"
|
||||
plain, styles = _markdown_to_signal(md)
|
||||
assert "Title" in plain
|
||||
assert "italic" in plain
|
||||
assert "• bullet" in plain
|
||||
sd = styles_for(plain, styles)
|
||||
assert "BOLD" in sd.get("Title", [])
|
||||
assert "ITALIC" in sd.get("italic", [])
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Table rendering
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_table_rendered_as_monospace():
|
||||
md = "| A | B |\n| - | - |\n| 1 | 2 |"
|
||||
plain, styles = _markdown_to_signal(md)
|
||||
assert "A" in plain and "B" in plain
|
||||
assert any("MONOSPACE" in s for s in styles)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Style range format
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_style_range_format():
|
||||
"""Each style entry must be 'start:length:STYLE'."""
|
||||
_, styles = _markdown_to_signal("**bold** text")
|
||||
for entry in styles:
|
||||
parts = entry.split(":")
|
||||
assert len(parts) == 3
|
||||
assert parts[0].isdigit()
|
||||
assert parts[1].isdigit()
|
||||
assert parts[2] in {"BOLD", "ITALIC", "STRIKETHROUGH", "MONOSPACE", "SPOILER"}
|
||||
|
||||
|
||||
def test_style_ranges_are_within_bounds():
|
||||
text = "hello **world** end"
|
||||
plain, styles = _markdown_to_signal(text)
|
||||
for entry in styles:
|
||||
start_s, length_s, _ = entry.split(":", 2)
|
||||
start, length = int(start_s), int(length_s)
|
||||
assert start >= 0
|
||||
assert start + length <= len(plain)
|
||||
Loading…
x
Reference in New Issue
Block a user